use std::fs;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile;
use crate::repo::marker as repo_marker;
#[derive(Debug, Deserialize)]
struct CodemapInputEntry {
path: String,
when_to_use: String,
#[serde(default)]
public_types: Vec<String>,
#[serde(default)]
public_functions: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CodemapEntry {
pub path: String,
pub when_to_use: String,
pub public_types: Vec<String>,
pub public_functions: Vec<String>,
pub imported_at: i64,
}
#[derive(Serialize)]
pub struct CodemapImportReport {
command: &'static str,
ok: bool,
imported: usize,
path: String,
}
impl CommandReport for CodemapImportReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn render_text(&self) {
if self.ok {
eprintln!("codemap: imported {} entries", self.imported);
eprintln!("codemap: database at {}", self.path);
} else {
eprintln!("codemap: import failed");
}
}
}
#[derive(Serialize)]
pub struct CodemapStatusReport {
command: &'static str,
ok: bool,
total_files: usize,
oldest_imported_at: Option<i64>,
newest_imported_at: Option<i64>,
db_path: String,
}
impl CommandReport for CodemapStatusReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn render_text(&self) {
eprintln!("codemap: {} files indexed", self.total_files);
if let Some(oldest) = self.oldest_imported_at {
eprintln!("codemap: oldest import: {oldest}");
}
if let Some(newest) = self.newest_imported_at {
eprintln!("codemap: newest import: {newest}");
}
eprintln!("codemap: database at {}", self.db_path);
}
}
#[derive(Serialize)]
pub struct CodemapQueryReport {
command: &'static str,
ok: bool,
entries: Vec<CodemapEntry>,
}
impl CommandReport for CodemapQueryReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn render_text(&self) {
for entry in &self.entries {
eprintln!("{}: {}", entry.path, entry.when_to_use);
}
eprintln!("codemap: {} entries", self.entries.len());
}
}
const CODEMAP_DB_SCHEMA_VERSION: u32 = 1;
fn open_codemap_db(path: &Path) -> Result<Connection> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create codemap db directory: {}",
parent.display()
)
})?;
}
let conn = Connection::open(path)
.with_context(|| format!("failed to open codemap database at {}", path.display()))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.context("failed to set codemap database PRAGMAs")?;
migrate_codemap_db(&conn)?;
Ok(conn)
}
fn migrate_codemap_db(conn: &Connection) -> Result<()> {
let version: u32 = conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.context("failed to read codemap user_version")?;
if version == CODEMAP_DB_SCHEMA_VERSION {
return Ok(());
}
if version > CODEMAP_DB_SCHEMA_VERSION {
bail!(
"codemap database has user_version {version}, but this build only supports up to {CODEMAP_DB_SCHEMA_VERSION}"
);
}
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
when_to_use TEXT NOT NULL,
public_types TEXT NOT NULL DEFAULT '[]',
public_functions TEXT NOT NULL DEFAULT '[]',
imported_at INTEGER NOT NULL
);",
)
.context("failed to create codemap schema")?;
conn.pragma_update(None, "user_version", CODEMAP_DB_SCHEMA_VERSION)
.context("failed to set codemap user_version")?;
Ok(())
}
fn import_entries(conn: &Connection, entries: &[CodemapInputEntry]) -> Result<usize> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock before UNIX epoch")?
.as_secs() as i64;
let tx = conn.unchecked_transaction()?;
let mut count = 0usize;
{
let mut stmt = tx.prepare(
"INSERT OR REPLACE INTO files (path, when_to_use, public_types, public_functions, imported_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
for entry in entries {
let types_json = serde_json::to_string(&entry.public_types)?;
let funcs_json = serde_json::to_string(&entry.public_functions)?;
stmt.execute(rusqlite::params![
entry.path,
entry.when_to_use,
types_json,
funcs_json,
now,
])?;
count += 1;
}
}
tx.commit()?;
Ok(count)
}
fn prefix_upper_bound(prefix: &str) -> String {
let mut bytes = prefix.as_bytes().to_vec();
while let Some(last) = bytes.last_mut() {
if *last < 0xFF {
*last += 1;
return String::from_utf8_lossy(&bytes).into_owned();
}
bytes.pop();
}
"\u{FFFF}".to_owned()
}
fn query_entries_from_db(
conn: &Connection,
prefix: Option<&str>,
limit: usize,
) -> Result<Vec<CodemapEntry>> {
let mut entries = Vec::new();
match prefix {
Some(pfx) => {
let upper = prefix_upper_bound(pfx);
let mut stmt = conn.prepare(
"SELECT path, when_to_use, public_types, public_functions, imported_at
FROM files WHERE path >= ?1 AND path < ?2 ORDER BY path LIMIT ?3",
)?;
let rows = stmt.query_map(rusqlite::params![pfx, upper, limit as i64], map_row)?;
for row in rows {
entries.push(row?);
}
}
None => {
let mut stmt = conn.prepare(
"SELECT path, when_to_use, public_types, public_functions, imported_at
FROM files ORDER BY path LIMIT ?1",
)?;
let rows = stmt.query_map(rusqlite::params![limit as i64], map_row)?;
for row in rows {
entries.push(row?);
}
}
}
Ok(entries)
}
fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodemapEntry> {
let path: String = row.get(0)?;
let types_json: String = row.get(2)?;
let funcs_json: String = row.get(3)?;
let public_types: Vec<String> = serde_json::from_str(&types_json).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(e))
})?;
let public_functions: Vec<String> = serde_json::from_str(&funcs_json).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(e))
})?;
Ok(CodemapEntry {
path,
when_to_use: row.get(1)?,
public_types,
public_functions,
imported_at: row.get(4)?,
})
}
fn count_entries_from_db(conn: &Connection) -> Result<usize> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
Ok(count as usize)
}
fn status_from_db(conn: &Connection) -> Result<(usize, Option<i64>, Option<i64>)> {
let count = count_entries_from_db(conn)?;
if count == 0 {
return Ok((0, None, None));
}
let oldest: i64 = conn.query_row("SELECT MIN(imported_at) FROM files", [], |row| row.get(0))?;
let newest: i64 = conn.query_row("SELECT MAX(imported_at) FROM files", [], |row| row.get(0))?;
Ok((count, Some(oldest), Some(newest)))
}
fn validate_entry(entry: &CodemapInputEntry) -> Result<()> {
if entry.path.is_empty() {
bail!("codemap entry has empty path");
}
let p = Path::new(&entry.path);
for component in p.components() {
match component {
Component::ParentDir => {
bail!("codemap entry path must not contain `..`: {}", entry.path);
}
Component::RootDir | Component::Prefix(_) => {
bail!(
"codemap entry path must be relative, got absolute: {}",
entry.path
);
}
_ => {}
}
}
if entry.when_to_use.is_empty() {
bail!("codemap entry for `{}` has empty when_to_use", entry.path);
}
Ok(())
}
fn resolve_db_path(repo_root: &Path, explicit_profile: Option<&str>) -> Result<PathBuf> {
let profile = profile::resolve(explicit_profile)?;
let layout = StateLayout::resolve(repo_root, profile)?;
let marker = repo_marker::load(repo_root)?.ok_or_else(|| {
anyhow::anyhow!(
"no CCD marker found at {}; run `ccd attach` first",
repo_root.join(repo_marker::MARKER_FILE).display()
)
})?;
layout.codemap_db_path(&marker.locality_id)
}
pub fn import(repo_root: &Path, explicit_profile: Option<&str>) -> Result<CodemapImportReport> {
let db_path = resolve_db_path(repo_root, explicit_profile)?;
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.context("failed to read codemap JSON from stdin")?;
let entries: Vec<CodemapInputEntry> =
serde_json::from_str(&input).context("failed to parse codemap JSON from stdin")?;
for entry in &entries {
validate_entry(entry)?;
}
let conn = open_codemap_db(&db_path)?;
let count = import_entries(&conn, &entries)?;
Ok(CodemapImportReport {
command: "codemap import",
ok: true,
imported: count,
path: db_path.display().to_string(),
})
}
pub fn status(repo_root: &Path, explicit_profile: Option<&str>) -> Result<CodemapStatusReport> {
let db_path = resolve_db_path(repo_root, explicit_profile)?;
let db_path_str = db_path.display().to_string();
if !db_path.exists() {
return Ok(CodemapStatusReport {
command: "codemap status",
ok: true,
total_files: 0,
oldest_imported_at: None,
newest_imported_at: None,
db_path: db_path_str,
});
}
let conn = open_codemap_db(&db_path)?;
let (total, oldest, newest) = status_from_db(&conn)?;
Ok(CodemapStatusReport {
command: "codemap status",
ok: true,
total_files: total,
oldest_imported_at: oldest,
newest_imported_at: newest,
db_path: db_path_str,
})
}
pub fn query(
repo_root: &Path,
explicit_profile: Option<&str>,
prefix: Option<&str>,
limit: usize,
) -> Result<Vec<CodemapEntry>> {
let db_path = resolve_db_path(repo_root, explicit_profile)?;
if !db_path.exists() {
return Ok(Vec::new());
}
let conn = open_codemap_db(&db_path)?;
query_entries_from_db(&conn, prefix, limit)
}
pub fn query_report(
repo_root: &Path,
explicit_profile: Option<&str>,
prefix: Option<&str>,
limit: usize,
) -> Result<CodemapQueryReport> {
let entries = query(repo_root, explicit_profile, prefix, limit)?;
Ok(CodemapQueryReport {
command: "codemap query",
ok: true,
entries,
})
}
pub fn count_entries_at(db_path: &Path) -> Result<usize> {
let conn = Connection::open(db_path)
.with_context(|| format!("failed to open codemap database at {}", db_path.display()))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.context("failed to set codemap database PRAGMAs")?;
migrate_codemap_db(&conn)?;
count_entries_from_db(&conn)
}