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}