1use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct PluginsState {
8 #[serde(default)]
9 pub marketplaces: Vec<Marketplace>,
10 #[serde(default)]
11 pub installed: Vec<InstalledPlugin>,
12 #[serde(default)]
13 pub trusted_hosts: Vec<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Marketplace {
18 pub name: String,
19 pub url: String,
20 #[serde(default)]
21 pub description: Option<String>,
22 #[serde(default)]
23 pub last_refreshed: Option<String>,
24 #[serde(default)]
25 pub cached_plugins: Vec<CachedPlugin>,
26 #[serde(default)]
29 pub repo_url: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CachedPlugin {
34 pub name: String,
35 pub source: String,
36 #[serde(default)]
37 pub version: Option<String>,
38 #[serde(default)]
39 pub description: Option<String>,
40 #[serde(default)]
41 pub index: Option<CachedPluginIndexMetadata>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CachedPluginIndexMetadata {
46 pub repository: String,
47 #[serde(default)]
48 pub subdir: Option<String>,
49 pub checksum_algorithm: String,
50 pub checksum_value: String,
51 #[serde(default)]
52 pub compatibility_synaps: Option<String>,
53 #[serde(default)]
54 pub compatibility_extension_protocol: Option<String>,
55 pub has_extension: bool,
56 #[serde(default)]
57 pub skills: Vec<String>,
58 #[serde(default)]
59 pub permissions: Vec<String>,
60 #[serde(default)]
61 pub hooks: Vec<String>,
62 #[serde(default)]
63 pub commands: Vec<String>,
64 #[serde(default)]
65 pub providers: Vec<crate::skills::plugin_index::PluginIndexProviderCapability>,
66 #[serde(default)]
67 pub trust_publisher: Option<String>,
68 #[serde(default)]
69 pub trust_homepage: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(tag = "state", rename_all = "snake_case")]
74pub enum SetupStatus {
75 NotRequired,
76 Succeeded { log_path: Option<String> },
77 Failed { message: String, log_path: Option<String> },
78}
79
80impl Default for SetupStatus {
81 fn default() -> Self { Self::NotRequired }
82}
83
84impl SetupStatus {
85 pub fn allows_extension_load(&self) -> bool {
86 !matches!(self, SetupStatus::Failed { .. })
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct InstalledPlugin {
92 pub name: String,
93 #[serde(default)]
94 pub marketplace: Option<String>,
95 pub source_url: String,
96 pub installed_commit: String,
97 #[serde(default)]
98 pub latest_commit: Option<String>,
99 pub installed_at: String,
100 #[serde(default)]
104 pub source_subdir: Option<String>,
105 #[serde(default)]
108 pub checksum_algorithm: Option<String>,
109 #[serde(default)]
110 pub checksum_value: Option<String>,
111 #[serde(default)]
114 pub setup_status: SetupStatus,
115}
116
117impl PluginsState {
118 pub fn load_from(path: &Path) -> std::io::Result<Self> {
119 match std::fs::read_to_string(path) {
120 Ok(c) => serde_json::from_str(&c)
121 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
122 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
123 Err(e) => Err(e),
124 }
125 }
126
127 pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
128 if let Some(p) = path.parent() {
129 std::fs::create_dir_all(p)?;
130 }
131 let json = serde_json::to_string_pretty(self)
132 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
133 let parent = path.parent().unwrap_or(Path::new("."));
135 let tmp = tempfile::NamedTempFile::new_in(parent)?;
136 std::fs::write(tmp.path(), json)?;
137 std::fs::File::open(tmp.path()).and_then(|f| f.sync_all())?;
139 tmp.persist(path).map_err(|e| e.error).map(|_| ())
140 }
141
142 pub fn default_path() -> std::path::PathBuf {
144 crate::config::resolve_write_path("plugins.json")
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn plugins_state_round_trip() {
154 let s = PluginsState {
155 marketplaces: vec![Marketplace {
156 name: "pi-skills".into(),
157 url: "https://github.com/maha-media/pi-skills".into(),
158 description: Some("…".into()),
159 last_refreshed: Some("2026-04-18T12:00:00Z".into()),
160 cached_plugins: vec![CachedPlugin {
161 name: "web".into(),
162 source: "https://github.com/maha-media/pi-web.git".into(),
163 version: Some("1.0".into()),
164 description: Some("Web tools".into()),
165 index: None,
166 }],
167 repo_url: Some("https://github.com/maha-media/pi-skills.git".into()),
168 }],
169 installed: vec![InstalledPlugin {
170 name: "web".into(),
171 marketplace: Some("pi-skills".into()),
172 source_url: "https://github.com/maha-media/pi-web.git".into(),
173 installed_commit: "abc123".into(),
174 latest_commit: Some("abc123".into()),
175 installed_at: "2026-04-18T12:01:00Z".into(),
176 source_subdir: None,
177 checksum_algorithm: None,
178 checksum_value: None,
179 setup_status: Default::default(),
180 }],
181 trusted_hosts: vec!["github.com/maha-media".into()],
182 };
183 let json = serde_json::to_string(&s).unwrap();
184 let back: PluginsState = serde_json::from_str(&json).unwrap();
185 assert_eq!(back.marketplaces.len(), 1);
186 assert_eq!(back.installed.len(), 1);
187 assert_eq!(back.trusted_hosts, vec!["github.com/maha-media"]);
188 }
189
190 #[test]
191 fn plugins_state_defaults_to_empty() {
192 let empty: PluginsState = serde_json::from_str("{}").unwrap();
193 assert!(empty.marketplaces.is_empty());
194 assert!(empty.installed.is_empty());
195 assert!(empty.trusted_hosts.is_empty());
196 }
197
198 #[test]
199 fn plugins_state_load_missing_file_is_empty() {
200 let dir = tempfile::tempdir().unwrap();
201 let path = dir.path().join("plugins.json");
202 let loaded = PluginsState::load_from(&path).unwrap();
203 assert!(loaded.marketplaces.is_empty());
204 }
205
206 #[test]
207 fn plugins_state_save_and_load_round_trip_on_disk() {
208 let dir = tempfile::tempdir().unwrap();
209 let path = dir.path().join("plugins.json");
210 let mut s = PluginsState::default();
211 s.trusted_hosts.push("github.com/x".into());
212 s.save_to(&path).unwrap();
213 let back = PluginsState::load_from(&path).unwrap();
214 assert_eq!(back.trusted_hosts, vec!["github.com/x"]);
215 }
216
217 #[test]
218 fn plugins_state_load_malformed_is_error() {
219 let dir = tempfile::tempdir().unwrap();
220 let path = dir.path().join("plugins.json");
221 std::fs::write(&path, "not json").unwrap();
222 assert!(PluginsState::load_from(&path).is_err());
223 }
224}