Skip to main content

caboodle/
prime.rs

1//! `tambo prime`: Tambo's agent primer, then each installed stack tool's
2//! `prime --brief` (the stack prime contract, `docs/stack/PRIME-CONTRACT.md`).
3//!
4//! The static half is `src/primer.md`, embedded at build time; the tambo skill
5//! carries a generated copy and the book includes the file, and
6//! `tests/prime_sync.rs` fails when either drifts.
7//!
8//! The live half finds stack tools on `PATH`, checks each one's identity (a
9//! same-named foreign `sd`, `st` or `dp` is never run with `prime`), then runs
10//! `<program> prime --brief`, all tools in parallel, stdin closed, output
11//! capped, inside one 1 s deadline per tool. It never installs, writes or
12//! registers anything. A tool whose prime exits non-zero or prints nothing is
13//! "no prime yet"; one past the deadline has its process group killed and is
14//! reported "timed out". Neither fails the command.
15
16use std::ffi::OsStr;
17use std::io::Read;
18use std::path::{Path, PathBuf};
19use std::process::{Command, ExitStatus, Stdio};
20use std::sync::mpsc;
21use std::thread;
22use std::time::{Duration, Instant};
23
24use serde_json::{json, Value};
25
26use crate::adapter::which_all;
27
28#[path = "prime_site.rs"]
29pub mod site;
30
31/// The static primer. One source: the skill and the book carry copies of it.
32pub const PRIMER: &str = include_str!("primer.md");
33
34/// The `--json` shape version (`schema` field). Bump only on a breaking change.
35pub const SCHEMA: &str = "stack-prime/1";
36
37/// Wall-clock budget per tool for the identity check plus `prime --brief`
38/// together. Tools run in parallel, so this bounds the whole live part.
39pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(1000);
40
41/// Most lines kept from one tool's brief (the contract's `--brief` limit).
42pub const BRIEF_LINES: usize = 10;
43
44/// Most bytes read from one tool's output; the rest is discarded.
45pub const READ_CAP: u64 = 64 * 1024;
46
47/// Serializes child spawns across probe threads (see `run_bounded`).
48static SPAWN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
49
50/// One stack tool `tambo prime` probes.
51pub struct Tool {
52    pub name: &'static str,
53    /// The one program run. `qp` is never probed: it is only ever an alias.
54    pub program: &'static str,
55    /// Arguments that make the program identify itself.
56    pub identity_argv: &'static [&'static str],
57    /// The identity output must contain one of these, or the program is a
58    /// different tool with the same name and is never run with `prime`.
59    pub identity: &'static [&'static str],
60}
61
62/// The stack binaries, in stack-table order (Tambo calls; Rikuy is a site). Member
63/// identities match their `members/*.toml` manifests (tested below); generic
64/// short names (`sd`, `st`, `dp`) are identified by text a same-named tool
65/// (sed replacement, suckless terminal) would not print.
66pub const ROSTER: &[Tool] = &[
67    Tool {
68        name: "quipu",
69        program: "quipu",
70        identity_argv: &["--help"],
71        identity: &["knowledge graph"],
72    },
73    Tool {
74        name: "camayoc",
75        program: "camayoc",
76        identity_argv: &["--version"],
77        identity: &["camayoc"],
78    },
79    Tool {
80        name: "quechua",
81        program: "quechua",
82        identity_argv: &["--version"],
83        identity: &["quechua"],
84    },
85    Tool {
86        name: "chaski",
87        program: "chaski",
88        identity_argv: &["--help"],
89        identity: &["a post-commit and time-driven reactor"],
90    },
91    Tool {
92        name: "bobbin",
93        program: "bobbin",
94        identity_argv: &["--version"],
95        identity: &["bobbin"],
96    },
97    Tool {
98        name: "yupana",
99        program: "yupana",
100        identity_argv: &["--version"],
101        identity: &["yupana"],
102    },
103    Tool {
104        name: "desire-path",
105        program: "dp",
106        identity_argv: &["--help"],
107        identity: &["failed AI tool calls"],
108    },
109    Tool {
110        name: "seeds",
111        program: "sd",
112        identity_argv: &["--help"],
113        identity: &[
114            "seeds is the beads-compatible tracker",
115            "seeds is the work-item tracker for the quipu stack",
116        ],
117    },
118    Tool {
119        name: "shantytown",
120        program: "st",
121        identity_argv: &["--help"],
122        identity: &["sling"],
123    },
124    Tool {
125        name: "shuttle",
126        program: "shuttle",
127        identity_argv: &["--help"],
128        identity: &["The `shuttle` CLI"],
129    },
130];
131
132/// What probing one tool found.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum Outcome {
135    NotInstalled,
136    /// A program with the tool's name that is not the stack tool; never run.
137    Foreign,
138    Primed(Vec<String>),
139    NoPrime,
140    TimedOut,
141}
142
143impl Outcome {
144    fn key(&self) -> &'static str {
145        match self {
146            Self::NotInstalled => "not-installed",
147            Self::Foreign => "foreign",
148            Self::Primed(_) => "primed",
149            Self::NoPrime => "no-prime",
150            Self::TimedOut => "timed-out",
151        }
152    }
153}
154
155#[derive(Debug, Clone)]
156pub struct Probe {
157    pub tool: &'static str,
158    pub program: Option<PathBuf>,
159    pub outcome: Outcome,
160    pub elapsed_ms: u128,
161}
162
163/// `(heading, text)` blocks of the primer; block 0 is the title.
164fn split(primer: &str) -> Vec<(&str, &str)> {
165    let mut starts = vec![(0usize, "")];
166    let mut offset = 0;
167    for line in primer.split_inclusive('\n') {
168        if let Some(h) = line.strip_prefix("## ") {
169            starts.push((offset, h.trim_end()));
170        }
171        offset += line.len();
172    }
173    starts
174        .iter()
175        .enumerate()
176        .map(|(i, &(start, heading))| {
177            let end = starts.get(i + 1).map_or(primer.len(), |s| s.0);
178            (heading, &primer[start..end])
179        })
180        .collect()
181}
182
183/// Section headings of the primer (`## ...`), in order.
184pub fn sections(primer: &str) -> Vec<&str> {
185    split(primer).into_iter().skip(1).map(|(h, _)| h).collect()
186}
187
188/// One section by case-insensitive heading prefix.
189pub fn section(primer: &str, wanted: &str) -> Result<String, String> {
190    let needle = wanted.trim().to_lowercase();
191    split(primer)
192        .into_iter()
193        .skip(1)
194        .find(|(h, _)| h.to_lowercase().starts_with(&needle))
195        .map(|(_, body)| body.to_string())
196        .ok_or_else(|| {
197            format!(
198                "unknown primer section: {wanted}. Sections: {}",
199                sections(primer).join(", ")
200            )
201        })
202}
203
204/// Kill the child's whole process group, so a grandchild cannot outlive it.
205#[cfg(unix)]
206fn kill_group(child: &mut std::process::Child) {
207    extern "C" {
208        fn kill(pid: i32, sig: i32) -> i32;
209    }
210    if let Ok(pid) = i32::try_from(child.id()) {
211        // SAFETY: plain syscall; the child leads its own group (process_group(0)).
212        unsafe {
213            kill(-pid, 9);
214        }
215    }
216    let _ = child.kill();
217    let _ = child.wait();
218}
219
220#[cfg(not(unix))]
221fn kill_group(child: &mut std::process::Child) {
222    let _ = child.kill();
223    let _ = child.wait();
224}
225
226/// Run `program args`, stdin closed, stdout and stderr each capped at
227/// [`READ_CAP`] (returned together, stdout first, when `stderr` is set; else
228/// stderr is dropped); kill its process group at `deadline`. `None` means timed
229/// out; `Some(Err(()))` means it could not start.
230fn run_bounded(
231    program: &Path,
232    args: &[&str],
233    home: Option<&Path>,
234    stderr: bool,
235    deadline: Instant,
236) -> Option<Result<(ExitStatus, Vec<u8>), ()>> {
237    let mut cmd = Command::new(program);
238    cmd.args(args)
239        .stdin(Stdio::null())
240        .stdout(Stdio::piped())
241        .stderr(if stderr {
242            Stdio::piped()
243        } else {
244            Stdio::null()
245        });
246    if let Some(home) = home {
247        cmd.env("HOME", home);
248    }
249    #[cfg(unix)]
250    std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
251    // One spawn at a time: where pipes are not created close-on-exec
252    // atomically (macOS), a concurrent fork can inherit another probe's pipe
253    // and hold it open, stalling that probe's reader until its deadline.
254    let spawned = {
255        let _guard = SPAWN_LOCK.lock().unwrap_or_else(|e| e.into_inner());
256        cmd.spawn()
257    };
258    let Ok(mut child) = spawned else {
259        return Some(Err(()));
260    };
261    // Read on threads: a chatty child must not block on a full pipe.
262    let readers: Vec<mpsc::Receiver<Vec<u8>>> = [
263        child
264            .stdout
265            .take()
266            .map(|o| Box::new(o) as Box<dyn Read + Send>),
267        child
268            .stderr
269            .take()
270            .map(|e| Box::new(e) as Box<dyn Read + Send>),
271    ]
272    .into_iter()
273    .flatten()
274    .map(|pipe| {
275        let (tx, rx) = mpsc::channel();
276        thread::spawn(move || {
277            let mut buf = Vec::new();
278            let _ = pipe.take(READ_CAP).read_to_end(&mut buf);
279            let _ = tx.send(buf);
280        });
281        rx
282    })
283    .collect();
284    let status = loop {
285        match child.try_wait() {
286            Ok(Some(status)) => break status,
287            Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(5)),
288            _ => {
289                kill_group(&mut child);
290                return None;
291            }
292        }
293    };
294    let mut bytes = Vec::new();
295    for rx in readers {
296        let remaining = deadline.saturating_duration_since(Instant::now());
297        match rx.recv_timeout(remaining.max(Duration::from_millis(20))) {
298            Ok(part) => bytes.extend(part),
299            Err(_) => {
300                // A background grandchild still holds the pipe, so the group
301                // is still alive: kill it rather than leave an orphan.
302                kill_group(&mut child);
303                return Some(Ok((status, bytes)));
304            }
305        }
306    }
307    Some(Ok((status, bytes)))
308}
309
310/// Is the program at `path` this stack tool? Probed with a throwaway HOME so
311/// identifying a tool never touches the user's config or store.
312fn identify(tool: &Tool, path: &Path, deadline: Instant) -> Option<bool> {
313    let home = tempfile::tempdir().ok()?;
314    let (_, bytes) = match run_bounded(path, tool.identity_argv, Some(home.path()), true, deadline)?
315    {
316        Ok(out) => out,
317        Err(()) => return Some(false),
318    };
319    let text = String::from_utf8_lossy(&bytes);
320    Some(tool.identity.iter().any(|marker| text.contains(marker)))
321}
322
323/// Identify, then run `prime --brief`, all inside one deadline.
324fn probe_one(tool: &Tool, path: &Path, timeout: Duration) -> Outcome {
325    let deadline = Instant::now() + timeout;
326    match identify(tool, path, deadline) {
327        None => return Outcome::TimedOut,
328        Some(false) => return Outcome::Foreign,
329        Some(true) => {}
330    }
331    let (status, bytes) = match run_bounded(path, &["prime", "--brief"], None, false, deadline) {
332        None => return Outcome::TimedOut,
333        Some(Err(())) => return Outcome::NoPrime,
334        Some(Ok(out)) => out,
335    };
336    let lines: Vec<String> = String::from_utf8_lossy(&bytes)
337        .lines()
338        .map(str::trim_end)
339        .filter(|l| !l.is_empty())
340        .map(str::to_owned)
341        .collect();
342    if !status.success() || lines.is_empty() {
343        return Outcome::NoPrime;
344    }
345    Outcome::Primed(lines)
346}
347
348/// Probe every roster tool on `path`, in parallel, each bounded by `timeout`.
349pub fn probe(path: Option<&OsStr>, timeout: Duration) -> Vec<Probe> {
350    let handles: Vec<_> = ROSTER
351        .iter()
352        .map(|tool| {
353            let program = which_all(tool.program, path).into_iter().next();
354            thread::spawn(move || {
355                let started = Instant::now();
356                let outcome = match &program {
357                    None => Outcome::NotInstalled,
358                    Some(p) => probe_one(tool, p, timeout),
359                };
360                Probe {
361                    tool: tool.name,
362                    program,
363                    outcome,
364                    elapsed_ms: started.elapsed().as_millis(),
365                }
366            })
367        })
368        .collect();
369    handles
370        .into_iter()
371        .zip(ROSTER)
372        .map(|(h, tool)| {
373            h.join().unwrap_or(Probe {
374                tool: tool.name,
375                program: None,
376                outcome: Outcome::NoPrime,
377                elapsed_ms: 0,
378            })
379        })
380        .collect()
381}
382
383fn names(probes: &[Probe], key: &str) -> Vec<&'static str> {
384    probes
385        .iter()
386        .filter(|p| p.outcome.key() == key)
387        .map(|p| p.tool)
388        .collect()
389}
390
391fn installed_count(probes: &[Probe]) -> usize {
392    probes
393        .iter()
394        .filter(|p| p.outcome != Outcome::NotInstalled)
395        .count()
396}
397
398/// Lines of one tool's brief, cut to [`BRIEF_LINES`] with a pointer to the rest.
399fn capped(tool: &str, lines: &[String]) -> Vec<String> {
400    if lines.len() <= BRIEF_LINES {
401        return lines.to_vec();
402    }
403    let mut out = lines[..BRIEF_LINES - 1].to_vec();
404    out.push(format!(
405        "... {} more lines; run `{tool} prime --brief`",
406        lines.len() + 1 - BRIEF_LINES
407    ));
408    out
409}
410
411/// The live stack section as primer-style Markdown.
412pub fn render_live(probes: &[Probe]) -> String {
413    let mut out = String::from("\n## Live state\n\n");
414    out.push_str(&format!(
415        "Stack tools on PATH: {} of {}.\n",
416        installed_count(probes),
417        probes.len()
418    ));
419    for p in probes {
420        let program = p
421            .program
422            .as_ref()
423            .map(|path| path.display().to_string())
424            .unwrap_or_default();
425        match &p.outcome {
426            Outcome::NotInstalled => {}
427            Outcome::Primed(lines) => {
428                out.push_str(&format!("\n### {} (`{program}`)\n\n", p.tool));
429                for line in capped(p.tool, lines) {
430                    out.push_str(&format!("    {line}\n"));
431                }
432            }
433            Outcome::NoPrime => {
434                out.push_str(&format!("\n### {} (`{program}`)\n\nno prime yet\n", p.tool))
435            }
436            Outcome::Foreign => out.push_str(&format!(
437                "\n### {} (`{program}`)\n\nnot the stack tool (a different program with that name); not run\n",
438                p.tool
439            )),
440            Outcome::TimedOut => out.push_str(&format!(
441                "\n### {} (`{program}`)\n\ntimed out; run `{program} prime` directly\n",
442                p.tool
443            )),
444        }
445    }
446    let missing = names(probes, "not-installed");
447    if !missing.is_empty() {
448        out.push_str(&format!("\nNot installed: {}.\n", missing.join(", ")));
449    }
450    out
451}
452
453/// `--brief --static`: the version line, "What Tambo is", the pointer line.
454pub fn render_static_brief() -> String {
455    let what = section(PRIMER, "what").unwrap_or_default();
456    let mut out = format!(
457        "tambo {}: installs and proves the Quipu stack\n",
458        env!("CARGO_PKG_VERSION")
459    );
460    for line in what.lines().skip(1).filter(|l| !l.trim().is_empty()) {
461        out.push_str(line);
462        out.push('\n');
463    }
464    out.push_str("Run `tambo prime` for the full primer.\n");
465    out
466}
467
468/// The contract's `--brief`: 5 to 10 lines.
469pub fn render_brief(probes: &[Probe]) -> String {
470    let list = |key| {
471        let n = names(probes, key);
472        if n.is_empty() {
473            "none".to_owned()
474        } else {
475            n.join(", ")
476        }
477    };
478    let mut out = format!(
479        "tambo {}: installs and proves the Quipu stack\n",
480        env!("CARGO_PKG_VERSION")
481    );
482    out.push_str(&format!(
483        "Stack tools on PATH: {} of {}\n",
484        installed_count(probes),
485        probes.len()
486    ));
487    out.push_str(&format!("Primed: {}\n", list("primed")));
488    out.push_str(&format!("No prime yet: {}\n", list("no-prime")));
489    let timed_out = names(probes, "timed-out");
490    if !timed_out.is_empty() {
491        out.push_str(&format!("Timed out: {}\n", timed_out.join(", ")));
492    }
493    let foreign = names(probes, "foreign");
494    if !foreign.is_empty() {
495        out.push_str(&format!("Not the stack tool: {}\n", foreign.join(", ")));
496    }
497    out.push_str(&format!("Not installed: {}\n", list("not-installed")));
498    out.push_str("Run `tambo prime` for the full primer.\n");
499    out
500}
501
502/// The contract's common `--json` shape.
503pub fn to_json(primer_text: Option<&str>, probes: Option<&[Probe]>, elapsed_ms: u128) -> Value {
504    let mut primer = json!({ "sections": sections(PRIMER) });
505    if let Some(text) = primer_text {
506        primer["text"] = json!(text);
507    }
508    let live = match probes {
509        None => json!({ "status": "skipped" }),
510        Some(probes) => {
511            let tools: Vec<Value> = probes
512                .iter()
513                .map(|p| {
514                    let mut v = json!({
515                        "tool": p.tool,
516                        "status": p.outcome.key(),
517                        "program": p.program.as_ref().map(|x| x.display().to_string()),
518                        "elapsed_ms": p.elapsed_ms,
519                    });
520                    if let Outcome::Primed(lines) = &p.outcome {
521                        v["brief"] = json!(capped(p.tool, lines));
522                    }
523                    v
524                })
525                .collect();
526            let partial = probes.iter().any(|p| p.outcome == Outcome::TimedOut);
527            json!({
528                "status": if partial { "partial" } else { "ok" },
529                "elapsed_ms": elapsed_ms,
530                "summary": { "tools": tools },
531            })
532        }
533    };
534    let status = if matches!(live["status"].as_str(), Some("ok" | "skipped")) {
535        "ok"
536    } else {
537        "degraded"
538    };
539    json!({
540        "schema": SCHEMA,
541        "tool": "tambo",
542        "version": env!("CARGO_PKG_VERSION"),
543        "primer": primer,
544        "live": live,
545        "status": status,
546    })
547}
548
549pub struct Options<'a> {
550    pub brief: bool,
551    pub json: bool,
552    pub offline: bool,
553    pub section: Option<&'a str>,
554    pub timeout: Duration,
555    pub rikuy_url: Option<&'a str>,
556}
557
558/// Entry point. Returns the text to print, or a usage error.
559pub fn run(opts: &Options) -> Result<String, String> {
560    let text = match opts.section {
561        Some(wanted) => section(PRIMER, wanted)?,
562        None => PRIMER.to_owned(),
563    };
564    let started = Instant::now();
565    let path = std::env::var_os("PATH");
566    let rikuy_url = opts
567        .rikuy_url
568        .map(str::to_owned)
569        .or_else(|| std::env::var("RIKUY_URL").ok());
570    let site_handle = (!opts.offline).then(|| {
571        let path = path.clone();
572        let timeout = opts.timeout;
573        thread::spawn(move || site::probe(rikuy_url.as_deref(), path.as_deref(), timeout))
574    });
575    let probes = (!opts.offline).then(|| probe(path.as_deref(), opts.timeout));
576    let site = site_handle.map(|h| {
577        h.join().unwrap_or(site::Site {
578            url: None,
579            status: "unreadable",
580            reachable: None,
581            generated_at: None,
582            revision: None,
583            reason: "reader_failed",
584            elapsed_ms: 0,
585        })
586    });
587    let elapsed = started.elapsed().as_millis();
588    if opts.json {
589        let primer_text = (!opts.brief).then_some(text.as_str());
590        let mut value = to_json(primer_text, probes.as_deref(), elapsed);
591        if let Some(site) = &site {
592            value["live"]["summary"]["sites"] = json!([site.value()]);
593            if site.reason != "url_not_configured" && site.status != "ok" {
594                // A binary deadline remains partial. Other site failures retain
595                // their own category; optional, unconfigured sites do not gate.
596                if value["live"]["status"] != "partial" {
597                    value["live"]["status"] = json!(site.status);
598                }
599                value["status"] = json!("degraded");
600            }
601        }
602        return Ok(format!(
603            "{}\n",
604            serde_json::to_string_pretty(&value).unwrap_or_default()
605        ));
606    }
607    if opts.brief {
608        return Ok(match &probes {
609            Some(probes) => {
610                let mut brief = render_brief(probes);
611                if let Some(site) = &site {
612                    let end = "Run `tambo prime` for the full primer.\n";
613                    brief = brief.replace(end, &format!("{}\n{end}", site.line()));
614                }
615                brief
616            }
617            None => render_static_brief(),
618        });
619    }
620    let mut out = text;
621    if let Some(probes) = &probes {
622        out.push_str(&render_live(probes));
623        if let Some(site) = &site {
624            out.push_str(&format!("\n### rikuy (static site)\n\n{}\n", site.line()));
625            out.push_str(&format!(
626                "Read: {} ({}); live brief state not measured.\n",
627                site.status, site.reason
628            ));
629        }
630    }
631    Ok(out)
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn primer_has_the_contract_sections_in_order() {
640        assert!(PRIMER.starts_with("# Tambo agent primer\n"));
641        let s = sections(PRIMER);
642        let required = [
643            "What Tambo is",
644            "Agent workflow",
645            "Core commands",
646            "MCP tools",
647            "Next steps",
648        ];
649        let positions: Vec<usize> = required
650            .iter()
651            .map(|r| {
652                s.iter()
653                    .position(|h| h == r)
654                    .unwrap_or_else(|| panic!("missing {r}"))
655            })
656            .collect();
657        assert!(positions.windows(2).all(|w| w[0] < w[1]), "{s:?}");
658        assert_eq!(s.last(), Some(&"Next steps"));
659    }
660
661    #[test]
662    fn section_matches_a_case_insensitive_prefix() {
663        assert!(section(PRIMER, "mcp").unwrap().starts_with("## MCP tools"));
664        assert!(section(PRIMER, "nope").unwrap_err().contains("Sections:"));
665    }
666
667    #[test]
668    fn static_brief_is_five_to_ten_lines() {
669        let n = render_static_brief().lines().count();
670        assert!((5..=10).contains(&n), "{n} lines");
671    }
672
673    #[test]
674    fn member_identities_match_their_manifests() {
675        for tool in ROSTER {
676            let Some(m) = crate::members::get(tool.name) else {
677                continue;
678            };
679            let p = m
680                .programs
681                .iter()
682                .find(|p| p.name == tool.program)
683                .unwrap_or_else(|| panic!("{}: no program {}", tool.name, tool.program));
684            let known: Vec<&str> = std::iter::once(&p.identity_contains)
685                .chain(&p.identity_alternatives)
686                .map(String::as_str)
687                .collect();
688            assert_eq!(
689                tool.identity_argv,
690                p.identity_argv.as_slice(),
691                "{}",
692                tool.name
693            );
694            assert_eq!(tool.identity, known.as_slice(), "{}", tool.name);
695        }
696    }
697
698    #[test]
699    fn roster_matches_the_stack_table() {
700        let table = include_str!("../docs/stack-table.md");
701        let listed: Vec<&str> = table
702            .lines()
703            .filter_map(|l| l.strip_prefix("| ["))
704            .filter_map(|l| l.split(']').next())
705            .filter(|name| !matches!(*name, "tambo" | "rikuy"))
706            .collect();
707        let roster: Vec<&str> = ROSTER.iter().map(|t| t.name).collect();
708        assert_eq!(roster, listed);
709    }
710
711    #[test]
712    fn roster_programs_agree_with_the_adapters() {
713        for tool in ROSTER {
714            assert_ne!(tool.program, "qp", "qp is an alias, never probed");
715            let Some(name) = crate::model::ToolName::parse(tool.name) else {
716                continue;
717            };
718            let known = crate::adapter::programs(name);
719            if !known.is_empty() {
720                assert!(known.contains(&tool.program), "{}: {known:?}", tool.name);
721            }
722        }
723    }
724
725    #[test]
726    fn long_briefs_are_capped() {
727        let lines: Vec<String> = (0..25).map(|i| format!("line {i}")).collect();
728        let out = capped("quipu", &lines);
729        assert_eq!(out.len(), BRIEF_LINES);
730        assert!(out.last().unwrap().contains("16 more lines"));
731    }
732
733    #[test]
734    fn brief_stays_within_ten_lines() {
735        let probes: Vec<Probe> = ROSTER
736            .iter()
737            .enumerate()
738            .map(|(i, tool)| Probe {
739                tool: tool.name,
740                program: Some(PathBuf::from(format!("/bin/{}", tool.program))),
741                outcome: match i % 5 {
742                    0 => Outcome::Primed(vec!["x".into()]),
743                    1 => Outcome::NoPrime,
744                    2 => Outcome::TimedOut,
745                    3 => Outcome::Foreign,
746                    _ => Outcome::NotInstalled,
747                },
748                elapsed_ms: 1,
749            })
750            .collect();
751        let n = render_brief(&probes).lines().count();
752        assert!((5..=10).contains(&n), "{n} lines");
753        let v = to_json(None, Some(&probes), 5);
754        assert_eq!(v["live"]["status"], "partial");
755        assert_eq!(v["status"], "degraded");
756        assert_eq!(v["schema"], SCHEMA);
757        assert!(v["primer"].get("text").is_none());
758    }
759}