use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginsState {
#[serde(default)]
pub marketplaces: Vec<Marketplace>,
#[serde(default)]
pub installed: Vec<InstalledPlugin>,
#[serde(default)]
pub trusted_hosts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Marketplace {
pub name: String,
pub url: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub last_refreshed: Option<String>,
#[serde(default)]
pub cached_plugins: Vec<CachedPlugin>,
#[serde(default)]
pub repo_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedPlugin {
pub name: String,
pub source: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub index: Option<CachedPluginIndexMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedPluginIndexMetadata {
pub repository: String,
#[serde(default)]
pub subdir: Option<String>,
pub checksum_algorithm: String,
pub checksum_value: String,
#[serde(default)]
pub compatibility_synaps: Option<String>,
#[serde(default)]
pub compatibility_extension_protocol: Option<String>,
pub has_extension: bool,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default)]
pub hooks: Vec<String>,
#[serde(default)]
pub commands: Vec<String>,
#[serde(default)]
pub providers: Vec<crate::skills::plugin_index::PluginIndexProviderCapability>,
#[serde(default)]
pub trust_publisher: Option<String>,
#[serde(default)]
pub trust_homepage: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum SetupStatus {
NotRequired,
Succeeded { log_path: Option<String> },
Failed { message: String, log_path: Option<String> },
}
impl Default for SetupStatus {
fn default() -> Self { Self::NotRequired }
}
impl SetupStatus {
pub fn allows_extension_load(&self) -> bool {
!matches!(self, SetupStatus::Failed { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledPlugin {
pub name: String,
#[serde(default)]
pub marketplace: Option<String>,
pub source_url: String,
pub installed_commit: String,
#[serde(default)]
pub latest_commit: Option<String>,
pub installed_at: String,
#[serde(default)]
pub source_subdir: Option<String>,
#[serde(default)]
pub checksum_algorithm: Option<String>,
#[serde(default)]
pub checksum_value: Option<String>,
#[serde(default)]
pub setup_status: SetupStatus,
}
impl PluginsState {
pub fn load_from(path: &Path) -> std::io::Result<Self> {
match std::fs::read_to_string(path) {
Ok(c) => serde_json::from_str(&c)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e),
}
}
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let parent = path.parent().unwrap_or(Path::new("."));
let tmp = tempfile::NamedTempFile::new_in(parent)?;
std::fs::write(tmp.path(), json)?;
std::fs::File::open(tmp.path()).and_then(|f| f.sync_all())?;
tmp.persist(path).map_err(|e| e.error).map(|_| ())
}
pub fn default_path() -> std::path::PathBuf {
crate::config::resolve_write_path("plugins.json")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugins_state_round_trip() {
let s = PluginsState {
marketplaces: vec![Marketplace {
name: "pi-skills".into(),
url: "https://github.com/maha-media/pi-skills".into(),
description: Some("…".into()),
last_refreshed: Some("2026-04-18T12:00:00Z".into()),
cached_plugins: vec![CachedPlugin {
name: "web".into(),
source: "https://github.com/maha-media/pi-web.git".into(),
version: Some("1.0".into()),
description: Some("Web tools".into()),
index: None,
}],
repo_url: Some("https://github.com/maha-media/pi-skills.git".into()),
}],
installed: vec![InstalledPlugin {
name: "web".into(),
marketplace: Some("pi-skills".into()),
source_url: "https://github.com/maha-media/pi-web.git".into(),
installed_commit: "abc123".into(),
latest_commit: Some("abc123".into()),
installed_at: "2026-04-18T12:01:00Z".into(),
source_subdir: None,
checksum_algorithm: None,
checksum_value: None,
setup_status: Default::default(),
}],
trusted_hosts: vec!["github.com/maha-media".into()],
};
let json = serde_json::to_string(&s).unwrap();
let back: PluginsState = serde_json::from_str(&json).unwrap();
assert_eq!(back.marketplaces.len(), 1);
assert_eq!(back.installed.len(), 1);
assert_eq!(back.trusted_hosts, vec!["github.com/maha-media"]);
}
#[test]
fn plugins_state_defaults_to_empty() {
let empty: PluginsState = serde_json::from_str("{}").unwrap();
assert!(empty.marketplaces.is_empty());
assert!(empty.installed.is_empty());
assert!(empty.trusted_hosts.is_empty());
}
#[test]
fn plugins_state_load_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugins.json");
let loaded = PluginsState::load_from(&path).unwrap();
assert!(loaded.marketplaces.is_empty());
}
#[test]
fn plugins_state_save_and_load_round_trip_on_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugins.json");
let mut s = PluginsState::default();
s.trusted_hosts.push("github.com/x".into());
s.save_to(&path).unwrap();
let back = PluginsState::load_from(&path).unwrap();
assert_eq!(back.trusted_hosts, vec!["github.com/x"]);
}
#[test]
fn plugins_state_load_malformed_is_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugins.json");
std::fs::write(&path, "not json").unwrap();
assert!(PluginsState::load_from(&path).is_err());
}
}