M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
//! Install registry for managing plugins installed from git repos or local directories.
|
||||
//!
|
||||
//! Tracks which repos have been cloned/symlinked into the managed install directory,
|
||||
//! along with the plugins discovered within each repo.
|
||||
//!
|
||||
//! The registry is persisted as `registry.json` in the install directory.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Default install directory name under `~/.kigi/`.
|
||||
const DEFAULT_INSTALL_DIR_NAME: &str = "installed-plugins";
|
||||
|
||||
/// Registry of installed repos and their plugins.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstallRegistry {
|
||||
/// Schema version for forward compatibility.
|
||||
pub version: u32,
|
||||
/// Installed repos, keyed by repo key (`<basename>-<hash8>`).
|
||||
pub repos: HashMap<String, InstalledRepo>,
|
||||
/// Absolute path to the install directory.
|
||||
#[serde(skip)]
|
||||
install_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// How a repo was installed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum InstallKind {
|
||||
/// Cloned from a remote git repo.
|
||||
Git {
|
||||
url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
git_ref: Option<String>,
|
||||
commit: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subdir: Option<String>,
|
||||
},
|
||||
/// Copied from a local directory (full tree snapshot under installed-plugins).
|
||||
Local {
|
||||
source_path: PathBuf,
|
||||
/// Optional plugin subdirectory selector used at install time (e.g.
|
||||
/// multi-package `path#plugins/foo`). Preserved so refresh rediscovers
|
||||
/// the same scope.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subdir: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single installed repo, which may contain one or more plugins.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstalledRepo {
|
||||
pub kind: InstallKind,
|
||||
pub installed_at: String,
|
||||
pub updated_at: String,
|
||||
/// Absolute path to the repo directory (or symlink) in the install dir.
|
||||
pub path: PathBuf,
|
||||
/// Plugins discovered within this repo.
|
||||
pub plugins: HashMap<String, RepoPlugin>,
|
||||
}
|
||||
|
||||
/// A plugin discovered within an installed repo.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RepoPlugin {
|
||||
/// Subdirectory within the repo (None if plugin is at repo root).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subdir: Option<String>,
|
||||
/// Plugin version from manifest (if available).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
fn paths_match_plugin_root(
|
||||
installed_plugin_root: &Path,
|
||||
plugin_root: &Path,
|
||||
plugin_canonical_root: &Path,
|
||||
) -> bool {
|
||||
installed_plugin_root == plugin_root
|
||||
|| installed_plugin_root == plugin_canonical_root
|
||||
|| dunce::canonicalize(installed_plugin_root)
|
||||
.ok()
|
||||
.is_some_and(|canonical| canonical == plugin_root || canonical == plugin_canonical_root)
|
||||
}
|
||||
|
||||
impl InstallRegistry {
|
||||
/// Load the registry from the resolved install directory.
|
||||
///
|
||||
/// If the registry file doesn't exist, returns an empty registry.
|
||||
pub fn load() -> Self {
|
||||
let install_dir = Self::resolve_install_dir();
|
||||
let registry_path = install_dir.join("registry.json");
|
||||
|
||||
match std::fs::read_to_string(®istry_path) {
|
||||
Ok(content) => match serde_json::from_str::<InstallRegistry>(&content) {
|
||||
Ok(mut reg) => {
|
||||
reg.install_dir = install_dir;
|
||||
reg
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"failed to parse install registry; starting fresh"
|
||||
);
|
||||
Self::empty(install_dir)
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::empty(install_dir),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"failed to read install registry; starting fresh"
|
||||
);
|
||||
Self::empty(install_dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an empty registry for the given install directory.
|
||||
pub fn empty(install_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
repos: HashMap::new(),
|
||||
install_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the registry to disk.
|
||||
pub fn save(&self) -> Result<(), InstallError> {
|
||||
self.save_atomic()
|
||||
}
|
||||
|
||||
pub fn save_atomic(&self) -> Result<(), InstallError> {
|
||||
std::fs::create_dir_all(&self.install_dir).map_err(|e| InstallError::Io {
|
||||
path: self.install_dir.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let registry_path = self.install_dir.join("registry.json");
|
||||
let content = serde_json::to_string_pretty(self).map_err(|e| InstallError::Json {
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if std::env::var_os("KIGI_TEST_FAIL_REGISTRY_SAVE_AFTER_SERIALIZE").is_some() {
|
||||
return Err(InstallError::InstallFailed {
|
||||
detail: "test-injected registry save failure".into(),
|
||||
});
|
||||
}
|
||||
let temp_path = self.install_dir.join(format!(
|
||||
".registry.json.tmp-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
std::fs::write(&temp_path, content).map_err(|e| InstallError::Io {
|
||||
path: temp_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if let Err(e) = std::fs::rename(&temp_path, ®istry_path) {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(InstallError::Io {
|
||||
path: registry_path,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a repo by its repo key.
|
||||
pub fn get_repo(&self, repo_key: &str) -> Option<&InstalledRepo> {
|
||||
self.repos.get(repo_key)
|
||||
}
|
||||
|
||||
/// Get a mutable reference to a repo by its repo key.
|
||||
pub fn get_repo_mut(&mut self, repo_key: &str) -> Option<&mut InstalledRepo> {
|
||||
self.repos.get_mut(repo_key)
|
||||
}
|
||||
|
||||
/// Find which repo a plugin belongs to.
|
||||
///
|
||||
/// Returns `(repo_key, repo, plugin)` if found.
|
||||
pub fn find_plugin(&self, plugin_name: &str) -> Option<(&str, &InstalledRepo, &RepoPlugin)> {
|
||||
for (repo_key, repo) in &self.repos {
|
||||
if let Some(plugin) = repo.plugins.get(plugin_name) {
|
||||
return Some((repo_key, repo, plugin));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn find_repo_key_by_plugin_root(
|
||||
&self,
|
||||
plugin_root: &Path,
|
||||
plugin_canonical_root: &Path,
|
||||
) -> Option<&str> {
|
||||
self.list().into_iter().find_map(|(repo_key, repo)| {
|
||||
repo.plugins.values().find_map(|plugin| {
|
||||
let installed_plugin_root = match plugin.subdir.as_deref() {
|
||||
Some(subdir) => repo.path.join(subdir),
|
||||
None => repo.path.clone(),
|
||||
};
|
||||
paths_match_plugin_root(&installed_plugin_root, plugin_root, plugin_canonical_root)
|
||||
.then_some(repo_key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert a repo into the registry.
|
||||
pub fn insert(&mut self, repo_key: String, repo: InstalledRepo) {
|
||||
self.repos.insert(repo_key, repo);
|
||||
}
|
||||
|
||||
/// Remove a repo from the registry.
|
||||
pub fn remove(&mut self, repo_key: &str) -> Option<InstalledRepo> {
|
||||
self.repos.remove(repo_key)
|
||||
}
|
||||
|
||||
/// List all installed repos.
|
||||
pub fn list(&self) -> Vec<(&str, &InstalledRepo)> {
|
||||
let mut entries: Vec<_> = self.repos.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
||||
entries.sort_by_key(|(k, _)| *k);
|
||||
entries
|
||||
}
|
||||
|
||||
/// Get the install directory path.
|
||||
pub fn install_dir(&self) -> &Path {
|
||||
&self.install_dir
|
||||
}
|
||||
|
||||
/// Resolve the install directory from config or default.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `[plugins].install_dir` from effective config (requirements > config > managed)
|
||||
/// 2. Default: `~/.kigi/installed-plugins/`
|
||||
pub fn resolve_install_dir() -> PathBuf {
|
||||
if let Some(dir) = Self::read_install_dir_from_config() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
kigi_config::kigi_home().join(DEFAULT_INSTALL_DIR_NAME)
|
||||
}
|
||||
|
||||
/// Read `[plugins].install_dir` from the effective config
|
||||
/// (managed_config.toml merged under config.toml — user wins).
|
||||
fn read_install_dir_from_config() -> Option<PathBuf> {
|
||||
let root = kigi_config::load_effective_config_disk_only().ok()?;
|
||||
let value = root.get("plugins")?.get("install_dir")?.as_str()?;
|
||||
let expanded = if let Some(stripped) = value.strip_prefix("~/") {
|
||||
dirs::home_dir()?.join(stripped)
|
||||
} else {
|
||||
PathBuf::from(value)
|
||||
};
|
||||
Some(expanded)
|
||||
}
|
||||
|
||||
/// Generate a unique repo key from a source identifier.
|
||||
///
|
||||
/// Format: `<basename>-<hash8>` where hash8 = first 8 hex chars of
|
||||
/// SHA-256(normalized source).
|
||||
///
|
||||
/// Examples:
|
||||
/// - `https://github.com/org-a/tools` → `tools-a1b2c3d4`
|
||||
/// - `/Users/me/projects/my-plugin` → `my-plugin-e5f6g7h8`
|
||||
pub fn repo_key(source: &str) -> String {
|
||||
let basename = source
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches(".git")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("plugin");
|
||||
|
||||
// Sanitize basename to kebab-case
|
||||
let sanitized: String = basename
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = sanitized.trim_matches('-');
|
||||
|
||||
// Hash the full source for uniqueness
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
source.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
let hash8 = format!("{:08x}", hash & 0xFFFFFFFF);
|
||||
|
||||
format!("{trimmed}-{hash8}")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum InstallError {
|
||||
#[error("I/O error on {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("JSON error: {detail}")]
|
||||
Json { detail: String },
|
||||
|
||||
#[error("plugin '{name}' not found in install registry")]
|
||||
PluginNotFound { name: String },
|
||||
|
||||
#[error("repo '{key}' already installed")]
|
||||
AlreadyInstalled { key: String },
|
||||
|
||||
#[error("SHA verification failed: expected {expected}, got {actual}")]
|
||||
ShaMismatch { expected: String, actual: String },
|
||||
|
||||
#[error("install failed: {detail}")]
|
||||
InstallFailed { detail: String },
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_https_url() {
|
||||
let key = InstallRegistry::repo_key("https://github.com/user/my-linter");
|
||||
assert!(key.starts_with("my-linter-"));
|
||||
assert_eq!(key.len(), "my-linter-".len() + 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_ssh_url() {
|
||||
let key = InstallRegistry::repo_key("git@github.com:user/my-plugin.git");
|
||||
assert!(key.starts_with("my-plugin-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_local_path() {
|
||||
let key = InstallRegistry::repo_key("/Users/me/projects/my-tools");
|
||||
assert!(key.starts_with("my-tools-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_collision_safety() {
|
||||
let key_a = InstallRegistry::repo_key("https://github.com/org-a/tools");
|
||||
let key_b = InstallRegistry::repo_key("https://github.com/org-b/tools");
|
||||
assert_ne!(
|
||||
key_a, key_b,
|
||||
"different sources should produce different keys"
|
||||
);
|
||||
assert!(key_a.starts_with("tools-"));
|
||||
assert!(key_b.starts_with("tools-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_crud() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
assert!(reg.repos.is_empty());
|
||||
assert!(reg.list().is_empty());
|
||||
|
||||
// Insert
|
||||
reg.insert(
|
||||
"test-repo-12345678".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://github.com/user/test".to_string(),
|
||||
git_ref: Some("main".to_string()),
|
||||
commit: "abc123".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
path: tmp.path().join("test-repo-12345678"),
|
||||
plugins: HashMap::from([(
|
||||
"my-plugin".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: None,
|
||||
version: Some("1.0.0".to_string()),
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(reg.repos.len(), 1);
|
||||
assert!(reg.get_repo("test-repo-12345678").is_some());
|
||||
assert!(reg.find_plugin("my-plugin").is_some());
|
||||
assert!(reg.find_plugin("nonexistent").is_none());
|
||||
|
||||
// Save and reload
|
||||
reg.save().unwrap();
|
||||
let registry_path = tmp.path().join("registry.json");
|
||||
assert!(registry_path.exists());
|
||||
|
||||
// Remove
|
||||
let removed = reg.remove("test-repo-12345678");
|
||||
assert!(removed.is_some());
|
||||
assert!(reg.repos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
|
||||
reg.insert(
|
||||
"my-linter-aabbccdd".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: PathBuf::from("/home/user/plugins/linter"),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: "2026-03-26T12:00:00Z".to_string(),
|
||||
updated_at: "2026-03-26T12:00:00Z".to_string(),
|
||||
path: tmp.path().join("my-linter-aabbccdd"),
|
||||
plugins: HashMap::from([
|
||||
(
|
||||
"lint-check".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("lint-check".to_string()),
|
||||
version: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"lint-fix".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("lint-fix".to_string()),
|
||||
version: Some("2.0.0".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
reg.save().unwrap();
|
||||
|
||||
// Read the JSON back and parse
|
||||
let content = std::fs::read_to_string(tmp.path().join("registry.json")).unwrap();
|
||||
let loaded: InstallRegistry = serde_json::from_str(&content).unwrap();
|
||||
|
||||
assert_eq!(loaded.version, 1);
|
||||
assert_eq!(loaded.repos.len(), 1);
|
||||
let repo = loaded.get_repo("my-linter-aabbccdd").unwrap();
|
||||
assert_eq!(repo.plugins.len(), 2);
|
||||
assert!(repo.plugins.contains_key("lint-check"));
|
||||
assert!(repo.plugins.contains_key("lint-fix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_plugin_across_repos() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
|
||||
reg.insert(
|
||||
"repo-a-11111111".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://example.com/a".to_string(),
|
||||
git_ref: None,
|
||||
commit: "aaa".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: tmp.path().join("repo-a-11111111"),
|
||||
plugins: HashMap::from([(
|
||||
"alpha".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: None,
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
reg.insert(
|
||||
"repo-b-22222222".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://example.com/b".to_string(),
|
||||
git_ref: None,
|
||||
commit: "bbb".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: tmp.path().join("repo-b-22222222"),
|
||||
plugins: HashMap::from([(
|
||||
"beta".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("beta".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
let (key, _, _) = reg.find_plugin("alpha").unwrap();
|
||||
assert_eq!(key, "repo-a-11111111");
|
||||
|
||||
let (key, _, plugin) = reg.find_plugin("beta").unwrap();
|
||||
assert_eq!(key, "repo-b-22222222");
|
||||
assert_eq!(plugin.subdir.as_deref(), Some("beta"));
|
||||
|
||||
assert!(reg.find_plugin("gamma").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_repo_key_by_plugin_root_handles_subdir_plugins() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo-a-11111111");
|
||||
let plugin_root = repo_root.join("plugins").join("nested");
|
||||
std::fs::create_dir_all(&plugin_root).unwrap();
|
||||
let canonical_plugin_root = dunce::canonicalize(&plugin_root).unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
reg.insert(
|
||||
"repo-a-11111111".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: repo_root.clone(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: repo_root,
|
||||
plugins: HashMap::from([(
|
||||
"nested".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("plugins/nested".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
reg.find_repo_key_by_plugin_root(&plugin_root, &canonical_plugin_root),
|
||||
Some("repo-a-11111111")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_kind_without_subdir_field_deserializes_to_none() {
|
||||
let json = r#"{"type":"Git","url":"https://example.com/r","commit":"abc"}"#;
|
||||
let kind: InstallKind = serde_json::from_str(json).unwrap();
|
||||
match kind {
|
||||
InstallKind::Git { url, subdir, .. } => {
|
||||
assert_eq!(url, "https://example.com/r");
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Git"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_kind_without_subdir_field_deserializes_to_none() {
|
||||
let json = r#"{"type":"Local","source_path":"/home/user/plugin"}"#;
|
||||
let kind: InstallKind = serde_json::from_str(json).unwrap();
|
||||
match kind {
|
||||
InstallKind::Local {
|
||||
source_path,
|
||||
subdir,
|
||||
} => {
|
||||
assert_eq!(source_path, PathBuf::from("/home/user/plugin"));
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_kind_with_subdir_round_trips() {
|
||||
let kind = InstallKind::Local {
|
||||
source_path: PathBuf::from("/home/user/workspace"),
|
||||
subdir: Some("plugins/foo".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
let back: InstallKind = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
InstallKind::Local { subdir, .. } => {
|
||||
assert_eq!(subdir.as_deref(), Some("plugins/foo"));
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user