Skip to main content

speccoder_runtime/format/speckit/
extensions.rs

1//! Spec Kit's installed extensions: the one reader of `.specify/extensions*`.
2//! `specify extension list --json` when the CLI is installed (read-only),
3//! else the registry files it keeps.
4
5use std::path::Path;
6use std::time::{Duration, Instant};
7
8use crate::format::{Extension, Inventory, InventorySource};
9
10/// The extensions installed in the project at `root`; `None` when it has
11/// no extension files and the CLI lists nothing.
12#[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
21/// The CLI's answer, if it is installed and answers within a few seconds.
22fn 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/// `specify extension list --json`: `[{id, version, enabled, source: {kind}}]`.
50#[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/// The registry (`.specify/extensions/.registry`) and the installed list
77/// (`.specify/extensions.yml`): an extension the list does not name is
78/// listed as not enabled.
79#[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
111/// The ids under `installed:` in `extensions.yml` (a YAML list of words).
112fn 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            // `installed: [bug, git]`
120            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}