use anyhow::{anyhow, Context, Result};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageRecord {
pub name: String,
pub slug: String,
pub description: Option<String>,
pub author: Option<String>,
pub license: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionRecord {
pub package_name: String,
pub version: String,
pub entry: String,
pub dependencies: String, pub checksum: String, pub published_at: String,
}
pub struct RegistryStore {
conn: Mutex<Connection>,
data_dir: PathBuf,
}
impl RegistryStore {
pub fn open(data_dir: &Path) -> Result<Self> {
fs::create_dir_all(data_dir)
.with_context(|| format!("Failed to create data dir: {}", data_dir.display()))?;
fs::create_dir_all(data_dir.join("packages"))
.context("Failed to create packages dir")?;
let db_path = data_dir.join("registry.db");
let conn = Connection::open(&db_path)
.with_context(|| format!("Failed to open database: {}", db_path.display()))?;
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS packages (
name TEXT PRIMARY KEY,
slug TEXT NOT NULL,
description TEXT,
author TEXT,
license TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS versions (
package_name TEXT NOT NULL,
version TEXT NOT NULL,
entry TEXT NOT NULL DEFAULT 'lib.jg',
dependencies TEXT NOT NULL DEFAULT '{}',
checksum TEXT NOT NULL,
published_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (package_name, version),
FOREIGN KEY (package_name) REFERENCES packages(name)
);
CREATE INDEX IF NOT EXISTS idx_versions_package ON versions(package_name);
",
)
.context("Failed to initialize database schema")?;
Ok(Self {
conn: Mutex::new(conn),
data_dir: data_dir.to_path_buf(),
})
}
pub fn publish(
&self,
name: &str,
version: &str,
slug: &str,
description: Option<&str>,
author: Option<&str>,
license: Option<&str>,
entry: &str,
dependencies: &serde_json::Value,
archive_bytes: &[u8],
) -> Result<()> {
semver::Version::parse(version)
.with_context(|| format!("Invalid semver: {}", version))?;
let mut hasher = Sha256::new();
hasher.update(archive_bytes);
let checksum = format!("{:x}", hasher.finalize());
let conn = self.conn.lock().map_err(|e| anyhow!("Lock poisoned: {}", e))?;
let exists: bool = conn.query_row(
"SELECT COUNT(*) > 0 FROM versions WHERE package_name = ?1 AND version = ?2",
params![name, version],
|row| row.get(0),
)?;
if exists {
return Err(anyhow!(
"Version {} of package '{}' already exists",
version,
name
));
}
conn.execute(
"INSERT INTO packages (name, slug, description, author, license)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(name) DO UPDATE SET
slug = ?2,
description = COALESCE(?3, description),
author = COALESCE(?4, author),
license = COALESCE(?5, license),
updated_at = datetime('now')",
params![name, slug, description, author, license],
)?;
conn.execute(
"INSERT INTO versions (package_name, version, entry, dependencies, checksum)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![name, version, entry, dependencies.to_string(), checksum],
)?;
let archive_path = self.archive_path(name, version);
if let Some(parent) = archive_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&archive_path, archive_bytes)
.with_context(|| format!("Failed to write archive: {}", archive_path.display()))?;
Ok(())
}
pub fn get_package(&self, name: &str) -> Result<Option<PackageRecord>> {
let conn = self.conn.lock().map_err(|e| anyhow!("Lock poisoned: {}", e))?;
let mut stmt = conn.prepare(
"SELECT name, slug, description, author, license, created_at, updated_at
FROM packages WHERE name = ?1",
)?;
let mut rows = stmt.query_map(params![name], |row| {
Ok(PackageRecord {
name: row.get(0)?,
slug: row.get(1)?,
description: row.get(2)?,
author: row.get(3)?,
license: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})?;
match rows.next() {
Some(r) => Ok(Some(r?)),
None => Ok(None),
}
}
pub fn get_versions(&self, name: &str) -> Result<Vec<VersionRecord>> {
let conn = self.conn.lock().map_err(|e| anyhow!("Lock poisoned: {}", e))?;
let mut stmt = conn.prepare(
"SELECT package_name, version, entry, dependencies, checksum, published_at
FROM versions WHERE package_name = ?1
ORDER BY published_at DESC",
)?;
let rows = stmt.query_map(params![name], |row| {
Ok(VersionRecord {
package_name: row.get(0)?,
version: row.get(1)?,
entry: row.get(2)?,
dependencies: row.get(3)?,
checksum: row.get(4)?,
published_at: row.get(5)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn get_version(&self, name: &str, version: &str) -> Result<Option<VersionRecord>> {
let conn = self.conn.lock().map_err(|e| anyhow!("Lock poisoned: {}", e))?;
let mut stmt = conn.prepare(
"SELECT package_name, version, entry, dependencies, checksum, published_at
FROM versions WHERE package_name = ?1 AND version = ?2",
)?;
let mut rows = stmt.query_map(params![name, version], |row| {
Ok(VersionRecord {
package_name: row.get(0)?,
version: row.get(1)?,
entry: row.get(2)?,
dependencies: row.get(3)?,
checksum: row.get(4)?,
published_at: row.get(5)?,
})
})?;
match rows.next() {
Some(r) => Ok(Some(r?)),
None => Ok(None),
}
}
pub fn archive_path(&self, name: &str, version: &str) -> PathBuf {
self.data_dir
.join("packages")
.join(name)
.join(format!("{}.tar.gz", version))
}
pub fn search(&self, query: &str) -> Result<Vec<PackageRecord>> {
let conn = self.conn.lock().map_err(|e| anyhow!("Lock poisoned: {}", e))?;
let pattern = format!("%{}%", query);
let mut stmt = conn.prepare(
"SELECT name, slug, description, author, license, created_at, updated_at
FROM packages
WHERE name LIKE ?1 OR slug LIKE ?1 OR description LIKE ?1
ORDER BY updated_at DESC
LIMIT 50",
)?;
let rows = stmt.query_map(params![pattern], |row| {
Ok(PackageRecord {
name: row.get(0)?,
slug: row.get(1)?,
description: row.get(2)?,
author: row.get(3)?,
license: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store(suffix: &str) -> (RegistryStore, PathBuf) {
let dir = std::env::temp_dir().join(format!("juglans_reg_test_{}", suffix));
let _ = fs::remove_dir_all(&dir);
let store = RegistryStore::open(&dir).unwrap();
(store, dir)
}
#[test]
fn test_open_creates_dirs_and_db() {
let (_, dir) = temp_store("open");
assert!(dir.join("registry.db").exists());
assert!(dir.join("packages").is_dir());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_publish_and_get() {
let (store, dir) = temp_store("pub_get");
let deps = serde_json::json!({"http-tools": "^2.0"});
store
.publish(
"sqlite-tools",
"1.0.0",
"sqlite",
Some("SQLite utilities"),
Some("ops"),
Some("MIT"),
"lib.jgflow",
&deps,
b"fake-archive-bytes",
)
.unwrap();
let pkg = store.get_package("sqlite-tools").unwrap().unwrap();
assert_eq!(pkg.name, "sqlite-tools");
assert_eq!(pkg.slug, "sqlite");
assert_eq!(pkg.description.as_deref(), Some("SQLite utilities"));
let versions = store.get_versions("sqlite-tools").unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].version, "1.0.0");
assert!(!versions[0].checksum.is_empty());
assert!(store.archive_path("sqlite-tools", "1.0.0").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_duplicate_version_rejected() {
let (store, dir) = temp_store("dup");
let deps = serde_json::json!({});
store
.publish(
"my-lib", "1.0.0", "my-lib", None, None, None, "lib.jg", &deps, b"v1",
)
.unwrap();
let result = store.publish(
"my-lib", "1.0.0", "my-lib", None, None, None, "lib.jg", &deps, b"v2",
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_multiple_versions() {
let (store, dir) = temp_store("multi_ver");
let deps = serde_json::json!({});
store
.publish(
"my-lib", "1.0.0", "my-lib", None, None, None, "lib.jg", &deps, b"v1",
)
.unwrap();
store
.publish(
"my-lib", "1.1.0", "my-lib", None, None, None, "lib.jg", &deps, b"v2",
)
.unwrap();
let versions = store.get_versions("my-lib").unwrap();
assert_eq!(versions.len(), 2);
let v = store.get_version("my-lib", "1.0.0").unwrap().unwrap();
assert_eq!(v.version, "1.0.0");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_search() {
let (store, dir) = temp_store("search");
let deps = serde_json::json!({});
store
.publish(
"sqlite-tools",
"1.0.0",
"sqlite",
Some("SQLite utilities"),
None,
None,
"lib.jg",
&deps,
b"a",
)
.unwrap();
store
.publish(
"http-client",
"1.0.0",
"http",
Some("HTTP client"),
None,
None,
"lib.jg",
&deps,
b"b",
)
.unwrap();
let results = store.search("sqlite").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "sqlite-tools");
let results = store.search("client").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "http-client");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_get_nonexistent() {
let (store, dir) = temp_store("noexist");
assert!(store.get_package("doesnt-exist").unwrap().is_none());
assert!(store.get_version("doesnt-exist", "1.0.0").unwrap().is_none());
let _ = fs::remove_dir_all(&dir);
}
}