Skip to main content

speccoder_runtime/extensions/
report.rs

1//! What `speccoder extensions`, Settings › Extensions, Needs You and
2//! `doctor` show: each extension with what it contributes in words, its
3//! trust, and — when it changed — what changed, widened first.
4
5use std::path::Path;
6
7use speccoder_core::extensions::{Extension, ExtensionsReport};
8use speccoder_core::hooks::TrustState;
9
10use super::{Loaded, trust};
11
12/// `e` as shown, against the trust records in `dir`.
13#[must_use]
14pub fn entry(e: &Loaded, dir: &Path) -> Extension {
15    let state = if e.usable() {
16        trust::state(dir, e)
17    } else {
18        TrustState::Untrusted
19    };
20    Extension {
21        name: e.name.clone(),
22        source: e.source,
23        version: e.version.clone(),
24        description: e.description.clone(),
25        folder: e.rel.clone(),
26        origin: e.origin.clone(),
27        digest: e.digest.clone(),
28        files: e.files.clone(),
29        contributions: e.contributions(),
30        trust: state,
31        changes: (state == TrustState::Changed)
32            .then(|| trust::changes(dir, e))
33            .flatten(),
34        trust_flag: format!("--trust-extension {}={}", e.name, e.digest),
35        problems: e.problems.clone(),
36    }
37}
38
39/// Every extension of the project at `root`. A trust record outlives an
40/// extension that is not here now (another branch has it); only `remove`
41/// forgets one.
42#[must_use]
43pub fn list(root: &Path, state: &Path) -> ExtensionsReport {
44    let dir = crate::hooks::dir(state, root);
45    let all = super::load(root);
46    ExtensionsReport {
47        extensions: all.iter().map(|e| entry(e, &dir)).collect(),
48        ..ExtensionsReport::default()
49    }
50}
51
52/// Trusts `name` exactly as shown to the person (`digest`), through `via`.
53///
54/// # Errors
55///
56/// No such extension, it cannot be loaded, or it changed since it was shown.
57pub fn trust_shown(
58    root: &Path,
59    state: &Path,
60    name: &str,
61    digest: &str,
62    via: &str,
63) -> Result<(), String> {
64    let all = super::load(root);
65    let Some(e) = all.iter().find(|e| e.name == name) else {
66        return Err(format!("there is no extension `{name}`"));
67    };
68    if !e.usable() {
69        return Err(format!(
70            "{name} cannot be loaded: {}",
71            e.problems.join("; ")
72        ));
73    }
74    if e.digest != digest {
75        return Err(format!(
76            "{name} changed since it was shown ({} now): review it again",
77            e.digest
78        ));
79    }
80    trust::record(
81        &crate::hooks::dir(state, root),
82        e,
83        &crate::approvals::who_in(root),
84        via,
85    );
86    Ok(())
87}
88
89/// The extensions waiting for trust, by name: what a run says once.
90#[must_use]
91pub fn waiting(root: &Path, state: &Path) -> Vec<String> {
92    let dir = crate::hooks::dir(state, root);
93    super::load(root)
94        .iter()
95        .filter(|e| e.usable() && trust::state(&dir, e) != TrustState::Trusted)
96        .map(|e| e.name.clone())
97        .collect()
98}
99
100/// What a run says once about extensions that wait for trust.
101#[must_use]
102pub fn run_notice(root: &Path, state: &Path) -> Option<String> {
103    let names = waiting(root, state);
104    (!names.is_empty()).then(|| {
105        format!(
106            "{} {} for your trust: nothing of {} runs (speccoder extensions trust {})",
107            names.join(", "),
108            if names.len() == 1 { "waits" } else { "wait" },
109            if names.len() == 1 { "it" } else { "them" },
110            names[0]
111        )
112    })
113}
114
115/// `doctor`'s lines: an extension that cannot be loaded, or waits for trust.
116#[must_use]
117pub fn doctor_lines(root: &Path, state: &Path) -> Vec<(bool, String)> {
118    let dir = crate::hooks::dir(state, root);
119    super::load(root)
120        .iter()
121        .filter_map(|e| {
122            if !e.usable() {
123                return Some((
124                    false,
125                    format!(
126                        "extension {} cannot be loaded: {}",
127                        e.name,
128                        e.problems.join("; ")
129                    ),
130                ));
131            }
132            match trust::state(&dir, e) {
133                TrustState::Trusted => None,
134                TrustState::Untrusted => Some((
135                    false,
136                    format!(
137                        "extension {} waits for your trust: speccoder extensions trust {}",
138                        e.name, e.name
139                    ),
140                )),
141                TrustState::Changed => Some((
142                    false,
143                    format!(
144                        "extension {} changed since you trusted it: speccoder extensions trust {}",
145                        e.name, e.name
146                    ),
147                )),
148            }
149        })
150        .collect()
151}
152
153// ── commands ───────────────────────────────────────────────────────────
154
155use speccoder_core::{CommandData, CommandResult, CommandStatus};
156
157fn result(message: &str, report: ExtensionsReport, status: CommandStatus) -> CommandResult {
158    CommandResult::new("extensions", status, message)
159        .with_data(CommandData::Extensions(Box::new(report)))
160}
161
162fn summary(r: &ExtensionsReport) -> String {
163    let waiting = r
164        .extensions
165        .iter()
166        .filter(|e| e.problems.is_empty() && e.trust != TrustState::Trusted)
167        .count();
168    let broken = r
169        .extensions
170        .iter()
171        .filter(|e| !e.problems.is_empty())
172        .count();
173    let mut s = match (r.extensions.len(), waiting) {
174        (0, _) => {
175            return "no extensions: `speccoder extensions add <folder | URL@commit>` (docs: extensions)"
176                .to_owned();
177        }
178        (1, 0) => "1 extension, trusted".to_owned(),
179        (n, 0) => format!("{n} extensions, all trusted"),
180        (n, w) => format!(
181            "{n} extension{} — {w} wait{} for your trust",
182            if n == 1 { "" } else { "s" },
183            if w == 1 { "s" } else { "" }
184        ),
185    };
186    if broken > 0 {
187        s.push_str(&format!(", {broken} cannot be loaded"));
188    }
189    s
190}
191
192/// `speccoder extensions`.
193#[must_use]
194pub fn list_cmd(root: &Path, state: &Path) -> CommandResult {
195    let report = list(root, state);
196    let message = summary(&report);
197    result(&message, report, CommandStatus::Ok)
198}
199
200/// `speccoder extensions add <folder | URL@commit>`.
201#[must_use]
202pub fn add_cmd(root: &Path, state: &Path, source: &str) -> CommandResult {
203    match super::add::add(root, source) {
204        Ok(e) => {
205            let mut report = list(root, state);
206            report.added = Some(e.name.clone());
207            result(
208                &format!(
209                    "added {} to {}: commit it, then trust it (speccoder extensions trust {}) — nothing of it runs until then",
210                    e.name, e.rel, e.name
211                ),
212                report,
213                CommandStatus::Ok,
214            )
215        }
216        Err(err) => {
217            let status = if err.contains("cannot be loaded") || err.contains("plugin.json") {
218                CommandStatus::ConfigError
219            } else {
220                CommandStatus::UsageError
221            };
222            let r = result(&err, list(root, state), status);
223            if status == CommandStatus::ConfigError {
224                r.with_code("EXTENSION-UNLOADABLE")
225            } else {
226                r
227            }
228        }
229    }
230}
231
232/// `speccoder extensions remove <name>`.
233#[must_use]
234pub fn remove_cmd(root: &Path, state: &Path, name: &str) -> CommandResult {
235    match super::add::remove(root, state, name) {
236        Ok(()) => {
237            let mut report = list(root, state);
238            report.removed = Some(name.to_owned());
239            result(
240                &format!("removed {name}: commit the change to remove it for everyone"),
241                report,
242                CommandStatus::Ok,
243            )
244        }
245        Err(err) => result(&err, list(root, state), CommandStatus::UsageError),
246    }
247}
248
249/// `speccoder extensions trust <name>`: trusted when `confirm` agrees,
250/// having seen the entry. Its observers hear what happens from now on.
251pub fn trust_cmd(
252    root: &Path,
253    state: &Path,
254    name: &str,
255    via: &str,
256    confirm: &mut dyn FnMut(&Extension) -> bool,
257) -> CommandResult {
258    let dir = crate::hooks::dir(state, root);
259    let all = super::load(root);
260    let Some(e) = all.iter().find(|e| e.name == name) else {
261        return result(
262            &format!("there is no extension `{name}`"),
263            list(root, state),
264            CommandStatus::NotFound,
265        );
266    };
267    let shown = entry(e, &dir);
268    if !e.usable() {
269        return result(
270            &format!("{name} cannot be loaded: {}", e.problems.join("; ")),
271            list(root, state),
272            CommandStatus::ConfigError,
273        )
274        .with_code("EXTENSION-UNLOADABLE");
275    }
276    if shown.trust == TrustState::Trusted {
277        return result(
278            &format!("{name} is trusted as it is"),
279            list(root, state),
280            CommandStatus::Ok,
281        );
282    }
283    if !confirm(&shown) {
284        return result(
285            &format!("{name} not trusted: nothing of it runs"),
286            list(root, state),
287            CommandStatus::NeedsHuman,
288        )
289        .with_code("EXTENSION-UNTRUSTED");
290    }
291    trust::record(&dir, e, &crate::approvals::who_in(root), via);
292    after_trust(root, &dir, e);
293    let mut report = list(root, state);
294    report.trusted = vec![name.to_owned()];
295    result(
296        &format!("trusted {name}: it runs from now on"),
297        report,
298        CommandStatus::Ok,
299    )
300}
301
302/// A newly trusted extension's observers hear what happens from now on;
303/// one trusted again after a change goes on where it was.
304fn after_trust(root: &Path, dir: &Path, e: &Loaded) {
305    for h in e.hooks.iter().filter(|h| h.at().is_none()) {
306        if !crate::hooks::deliver::has_cursor(dir, &h.name) {
307            crate::hooks::deliver::start_here(dir, root, &h.name);
308        }
309    }
310}
311
312/// Trusts from an interface that showed `digest` (the web's sheet).
313///
314/// # Errors
315///
316/// As [`trust_shown`].
317pub fn trust_from_sheet(root: &Path, state: &Path, name: &str, digest: &str) -> Result<(), String> {
318    trust_shown(root, state, name, digest, "web")?;
319    let dir = crate::hooks::dir(state, root);
320    if let Some(e) = super::load(root).iter().find(|e| e.name == name) {
321        after_trust(root, &dir, e);
322    }
323    Ok(())
324}
325
326/// `--trust-extension name=sha256:…` for this command.
327///
328/// # Errors
329///
330/// A flag is malformed, names no extension, or a digest that is not current.
331pub fn apply_flags(root: &Path, state: &Path, flags: &[String]) -> Result<(), Box<CommandResult>> {
332    if flags.is_empty() {
333        return Ok(());
334    }
335    let parsed = flags
336        .iter()
337        .map(|f| trust::parse_flag(f))
338        .collect::<Result<Vec<_>, _>>()
339        .map_err(|e| {
340            Box::new(
341                CommandResult::new("extensions", CommandStatus::UsageError, &e).with_code("USAGE"),
342            )
343        })?;
344    let dir = crate::hooks::dir(state, root);
345    let all = super::load(root);
346    trust::apply_flags(&dir, &all, &parsed, &crate::approvals::who_in(root)).map_err(|e| {
347        Box::new(
348            CommandResult::new("extensions", CommandStatus::NeedsHuman, &e)
349                .with_code("EXTENSION-UNTRUSTED"),
350        )
351    })?;
352    for (name, _) in &parsed {
353        if let Some(e) = all.iter().find(|e| &e.name == name) {
354            after_trust(root, &dir, e);
355        }
356    }
357    Ok(())
358}