speccoder_runtime/format/speckit/
extensions.rs1use std::path::Path;
6use std::time::{Duration, Instant};
7
8use crate::format::{Extension, Inventory, InventorySource};
9
10#[must_use]
13pub fn inventory(root: &Path) -> Option<Inventory> {
14 if !root.join(".specify/extensions").is_dir() && !root.join(".specify/extensions.yml").is_file()
15 {
16 return None;
17 }
18 cli(root).or_else(|| files(root))
19}
20
21fn cli(root: &Path) -> Option<Inventory> {
23 let mut child = std::process::Command::new("specify")
24 .args(["extension", "list", "--json"])
25 .current_dir(root)
26 .stdin(std::process::Stdio::null())
27 .stdout(std::process::Stdio::piped())
28 .stderr(std::process::Stdio::null())
29 .spawn()
30 .ok()?;
31 let began = Instant::now();
32 loop {
33 match child.try_wait() {
34 Ok(Some(status)) if status.success() => break,
35 Ok(Some(_)) | Err(_) => return None,
36 Ok(None) if began.elapsed() > Duration::from_secs(20) => {
37 let _ = child.kill();
38 let _ = child.wait();
39 return None;
40 }
41 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
42 }
43 }
44 let mut out = String::new();
45 std::io::Read::read_to_string(child.stdout.as_mut()?, &mut out).ok()?;
46 parse_cli(&out)
47}
48
49#[must_use]
51pub fn parse_cli(text: &str) -> Option<Inventory> {
52 let start = text.find('[')?;
53 let v: serde_json::Value = serde_json::from_str(text[start..].trim()).ok()?;
54 let entries = v
55 .as_array()?
56 .iter()
57 .filter_map(|e| {
58 Some(Extension {
59 id: e["id"].as_str()?.to_owned(),
60 version: e["version"].as_str().unwrap_or_default().to_owned(),
61 enabled: e["enabled"].as_bool().unwrap_or(false),
62 source: e["source"]["kind"]
63 .as_str()
64 .or_else(|| e["source"].as_str())
65 .unwrap_or_default()
66 .to_owned(),
67 })
68 })
69 .collect();
70 Some(Inventory {
71 source: InventorySource::Cli,
72 entries,
73 })
74}
75
76#[must_use]
80pub fn files(root: &Path) -> Option<Inventory> {
81 let registry: serde_json::Value =
82 std::fs::read_to_string(root.join(".specify/extensions/.registry"))
83 .ok()
84 .and_then(|t| serde_json::from_str(&t).ok())
85 .unwrap_or_default();
86 let installed = std::fs::read_to_string(root.join(".specify/extensions.yml"))
87 .ok()
88 .map(|t| installed_list(&t));
89 let mut entries: Vec<Extension> = registry["extensions"]
90 .as_object()
91 .into_iter()
92 .flatten()
93 .map(|(id, e)| Extension {
94 id: id.clone(),
95 version: e["version"].as_str().unwrap_or_default().to_owned(),
96 enabled: e["enabled"].as_bool().unwrap_or(false)
97 && installed.as_ref().is_none_or(|i| i.contains(id)),
98 source: e["source"].as_str().unwrap_or_default().to_owned(),
99 })
100 .collect();
101 entries.sort_by(|a, b| a.id.cmp(&b.id));
102 if entries.is_empty() && installed.is_none() {
103 return None;
104 }
105 Some(Inventory {
106 source: InventorySource::Files,
107 entries,
108 })
109}
110
111fn installed_list(text: &str) -> Vec<String> {
113 let mut out = Vec::new();
114 let mut inside = false;
115 for line in text.lines() {
116 if line.starts_with("installed:") {
117 inside = true;
118 let rest = line.trim_start_matches("installed:").trim();
119 if let Some(list) = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
121 out.extend(
122 list.split(',')
123 .map(|w| w.trim().trim_matches(['"', '\'']).to_owned())
124 .filter(|w| !w.is_empty()),
125 );
126 inside = false;
127 }
128 continue;
129 }
130 if inside {
131 match line.trim_start().strip_prefix("- ") {
132 Some(id) if line.starts_with([' ', '-']) => {
133 out.push(id.trim().trim_matches(['"', '\'']).to_owned());
134 }
135 _ if line.trim().is_empty() => {}
136 _ => inside = false,
137 }
138 }
139 }
140 out
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn the_installed_list_is_read_in_both_yaml_forms() {
149 assert_eq!(
150 installed_list("installed:\n- bug\n- git\nsettings:\n x: 1\n"),
151 ["bug", "git"]
152 );
153 assert_eq!(
154 installed_list("installed: [bug, \"git\"]\n"),
155 ["bug", "git"]
156 );
157 assert!(installed_list("settings: {}\n").is_empty());
158 }
159
160 #[test]
161 fn the_cli_answer_names_id_version_state_and_source() {
162 let inv = parse_cli("[{\"id\": \"bug\", \"version\": \"1.0.1\", \"enabled\": false, \"source\": {\"kind\": \"local\"}}]")
163 .unwrap_or_else(|| panic!("parsed"));
164 assert_eq!(inv.source, InventorySource::Cli);
165 assert_eq!(inv.entries[0].source, "local");
166 assert!(inv.enabled("bug").is_none());
167 }
168}
169
170pub const DIR: &str = ".specify/extensions";
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct Manifest {
176 pub id: Option<String>,
178 pub version: Option<String>,
180 pub description: Option<String>,
182 pub commands: Vec<String>,
184 pub hooks: Vec<(String, String, Option<String>, bool)>,
186}
187
188#[must_use]
192pub fn folders(project: &Path) -> Vec<std::path::PathBuf> {
193 let listed = files(project);
194 let mut out: Vec<std::path::PathBuf> = std::fs::read_dir(project.join(DIR))
195 .into_iter()
196 .flatten()
197 .flatten()
198 .map(|e| e.path())
199 .filter(|p| p.join("extension.yml").is_file())
200 .filter(|p| {
201 let id = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
202 listed
203 .as_ref()
204 .is_none_or(|i| i.entries.is_empty() || i.enabled(id).is_some())
205 })
206 .collect();
207 out.sort();
208 out
209}
210
211pub fn manifest(folder: &Path) -> Result<Manifest, String> {
217 let text = std::fs::read_to_string(folder.join("extension.yml"))
218 .map_err(|e| format!("extension.yml: {e}"))?;
219 let v: serde_json::Value =
220 serde_saphyr::from_str(&text).map_err(|e| format!("extension.yml: {e}"))?;
221 let ext = &v["extension"];
222 let mut hooks = Vec::new();
223 if let Some(all) = v["hooks"].as_object() {
224 for (event, h) in all {
225 for h in h.as_array().cloned().unwrap_or_else(|| vec![h.clone()]) {
226 if let Some(command) = h["command"].as_str() {
227 hooks.push((
228 event.clone(),
229 command.to_owned(),
230 h["prompt"].as_str().map(str::to_owned),
231 h["optional"].as_bool() == Some(false),
232 ));
233 }
234 }
235 }
236 }
237 Ok(Manifest {
238 id: ext["id"].as_str().map(str::to_owned),
239 version: ext["version"].as_str().map(str::to_owned),
240 description: ext["description"].as_str().map(str::to_owned),
241 commands: v["provides"]["commands"]
242 .as_array()
243 .into_iter()
244 .flatten()
245 .filter_map(|c| c["name"].as_str().map(str::to_owned))
246 .collect(),
247 hooks,
248 })
249}
250
251#[must_use]
254pub fn skill_of(command: &str) -> String {
255 command.replace('.', "-")
256}