Skip to main content

borg/install/
setup.rs

1//! `borg setup`: from a fresh install to a working borg in one guided command.
2//!
3//! usage: borg setup [--yes] [--no-vectors] [--harness LIST] [--private DIR...]
4//!                   [--team NAME --paths DIR... [--relay URL|disabled] | --join TICKET [--paths DIR...]] [--home DIR]
5//!
6//! Steps, each saying what it will do: what is here (harness CLIs, the model CLI, git identity, an
7//! existing config), the collectives (setup/collectives.rs: personal, team or company, off paths), the
8//! wiring (`install::plan`: hooks or adapter, and the `borg` skill). Then one plan: the config diff, the
9//! mesh command, the vector model, the wiring diff, and nothing is written before the engineer confirms
10//! it. After that (setup/steps.rs): the config, with a backup (setup/config.rs: an existing one is only
11//! ever extended); `borg mesh init` or `join`; the vector model and indexes, on by default (a failure is a
12//! WARN, never the end of setup); the wiring; an optional import; `borg doctor`.
13//!
14//! Questions read stdin (setup/prompt.rs); without a terminal setup refuses unless `--yes` takes every
15//! default. Flags answer their question. A second run changes nothing. `--home DIR` isolates as `borg
16//! install --home` does (`super::home_overrides`), so a test never reaches a real config or store.
17
18mod collectives;
19mod config;
20mod prompt;
21mod steps;
22
23use std::io::IsTerminal;
24use std::path::Path;
25
26use crate::harness::{self, Harness};
27use crate::install::{self, Action, Change, Targets};
28use crate::{collective, learn, runner};
29use collectives::Mesh;
30use prompt::Prompt;
31
32const USAGE: &str = "usage: borg setup [--yes] [--no-vectors] [--harness HARNESS,...|all] [--private DIR...] \
33[--team NAME --paths DIR... [--relay URL|disabled]] [--join TICKET [--paths DIR...]] [--home DIR]";
34
35/// The flags (completions.rs lists these).
36pub const FLAGS: &[&str] = &["--yes", "--no-vectors", "--harness", "--private", "--team", "--paths", "--relay", "--join", "--home"];
37
38/// The flags; each answers its question.
39#[derive(Debug, Default)]
40pub struct Opts {
41    yes: bool,
42    no_vectors: bool,
43    harness: Option<String>,
44    join: Option<String>,
45    team: Option<String>,
46    relay: Option<String>,
47    home: Option<String>,
48    /// Some when `--private` was given.
49    private: Option<Vec<String>>,
50    paths: Vec<String>,
51}
52
53impl Opts {
54    /// Sets `flag` from the arguments after it; how many of them it took.
55    fn set(&mut self, flag: &str, rest: &[&str]) -> Result<usize, String> {
56        let values: Vec<String> = rest.iter().take_while(|a| !a.starts_with("--")).map(|a| (*a).to_string()).collect();
57        match flag {
58            "--yes" | "-y" => self.yes = true,
59            "--no-vectors" => self.no_vectors = true,
60            "--private" => self.private = Some(values.clone()),
61            "--paths" => self.paths.clone_from(&values),
62            _ => {
63                let slot = self.value(flag).ok_or(format!("unknown flag {flag}"))?;
64                *slot = Some(values.first().ok_or(format!("{flag} needs a value"))?.clone());
65                return Ok(1);
66            }
67        }
68        Ok(if matches!(flag, "--private" | "--paths") { values.len() } else { 0 })
69    }
70
71    fn value(&mut self, flag: &str) -> Option<&mut Option<String>> {
72        match flag {
73            "--harness" => Some(&mut self.harness),
74            "--join" => Some(&mut self.join),
75            "--team" => Some(&mut self.team),
76            "--relay" => Some(&mut self.relay),
77            "--home" => Some(&mut self.home),
78            _ => None,
79        }
80    }
81
82    /// Flags that only make sense together.
83    fn check(&self) -> Result<(), String> {
84        if self.team.is_some() && self.join.is_some() {
85            return Err("--team founds a collective, --join joins one: not both".into());
86        }
87        if !self.paths.is_empty() && self.team.is_none() && self.join.is_none() {
88            return Err("--paths names a team collective's repos: with --team or --join".into());
89        }
90        if self.yes && self.team.is_some() && self.paths.is_empty() {
91            return Err("--yes --team NAME needs --paths DIR...: a team collective without repo directories routes no repo".into());
92        }
93        self.values()
94    }
95
96    /// The values: a relay only for a founded collective, an http(s) one; a ticket that looks like one.
97    fn values(&self) -> Result<(), String> {
98        if self.relay.is_some() && self.team.is_none() {
99            return Err("--relay is the founded collective's (--team); a joiner gets it from the ticket".into());
100        }
101        self.join.as_deref().map_or(Ok(()), collectives::ticket)?;
102        self.relay.as_deref().map_or(Ok(None), collectives::relay_value).map(drop)
103    }
104}
105
106fn parse(args: &[&str]) -> Result<Opts, String> {
107    let mut o = Opts::default();
108    let mut i = 0;
109    while let Some(flag) = args.get(i) {
110        i += 1 + o.set(flag, &args[i + 1..])?;
111    }
112    o.check()?;
113    Ok(o)
114}
115
116/// `borg setup`; the exit code.
117#[must_use]
118pub fn main(args: &[&str]) -> i32 {
119    if args.iter().any(|a| matches!(*a, "-h" | "--help")) {
120        println!("{USAGE}");
121        return 0;
122    }
123    let o = match parse(args) {
124        Ok(o) => o,
125        Err(e) => {
126            eprintln!("borg setup: {e}\n{USAGE}");
127            return 2;
128        }
129    };
130    if !o.yes && !std::io::stdin().is_terminal() {
131        eprintln!("borg setup asks questions and needs a terminal; --yes takes every default without asking");
132        return 2;
133    }
134    if let Some(h) = &o.home {
135        install::isolate("setup", Path::new(h));
136    }
137    let mut stdin = std::io::stdin().lock();
138    let mut ask = Prompt::new(o.yes, &mut stdin);
139    run(&o, &mut ask).unwrap_or_else(|e| {
140        eprintln!("borg setup: {e}");
141        1
142    })
143}
144
145/// A harness and the changes that wire it.
146type Wire = (&'static dyn Harness, Vec<Change>);
147
148/// Everything setup will do, shown before the confirmation.
149struct Plan {
150    config: Option<Change>,
151    mesh: Option<Mesh>,
152    /// What the vector step will do, if anything.
153    vectors: Option<String>,
154    wiring: Vec<Wire>,
155}
156
157fn run(o: &Opts, ask: &mut Prompt) -> Result<i32, String> {
158    let t = o.home.as_deref().map_or_else(Targets::from_env, |h| Targets::under(Path::new(h)));
159    ask.heading("1. What is here");
160    let found = detect(&t);
161    ask.heading("2. Collectives");
162    let (want, mesh) = collectives::choose(o, ask, collective::config_path().exists())?;
163    let config = config::change(&collective::config_path(), &want)?;
164    ask.heading("3. Wiring");
165    let wiring = wiring(&harnesses(o, ask, &found)?, &t)?;
166    let mesh = mesh.filter(|m| !steps::done(m));
167    let plan = Plan { config, mesh, vectors: steps::vectors_plan(o.no_vectors), wiring };
168    ask.heading("4. The plan");
169    if !show(&plan, &t) {
170        println!("nothing to change");
171    } else if !ask.yes_no("Do all of this?", true) {
172        println!("nothing written");
173        return Ok(0);
174    }
175    let stamp = format!("{}Z", install::utc(learn::now_ms()));
176    let mut warns = Vec::new();
177    // Wiring without the config would put every repo into the implicit `default` team collective.
178    let (code, mesh) = match plan.config.as_ref().map_or(0, |c| steps::config(c, &stamp)) {
179        0 => act(o, &plan, ask, &stamp, &mut warns),
180        code => {
181            warns
182                .push("nothing else was done (mesh, vectors, wiring): the collectives config could not be written; fix that and run `borg setup` again".into());
183            (code, None)
184        }
185    };
186    ask.heading("5. Check");
187    Ok(verify(o, &warns, mesh).max(code))
188}
189
190/// After the config is written: the mesh command, the vectors, the wiring, the import. The exit code (1
191/// for a failed mesh command or write) and the mesh command that finished.
192fn act<'p>(o: &Opts, plan: &'p Plan, ask: &mut Prompt, stamp: &str, warns: &mut Vec<String>) -> (i32, Option<&'p Mesh>) {
193    let mesh = plan.mesh.as_ref().filter(|m| steps::mesh(m, ask, warns));
194    let failed = i32::from(plan.mesh.is_some() && mesh.is_none());
195    steps::vectors(o.no_vectors, ask, warns);
196    let code = steps::wire(&plan.wiring, stamp).max(failed);
197    steps::import(ask);
198    (code, mesh)
199}
200
201/// Step 1: harness CLIs on PATH (the ones wired by default), the model CLI, git identity, the config.
202fn detect(t: &Targets) -> Vec<&'static dyn Harness> {
203    let found: Vec<&'static dyn Harness> = harness::visible().filter(|h| install::which(h.probe().cli).is_some()).collect();
204    for h in harness::visible() {
205        let cli = if found.iter().any(|f| f.name() == h.name()) { "installed" } else { "not installed" };
206        println!("{:<9} {cli}; {}", h.name(), install::wiring(h, t));
207    }
208    let spec = std::env::var("BORG_PREDICT_MODEL").ok();
209    println!("{}", runner::availability(spec.as_deref(), &runner::on_path).unwrap_or_else(|e| e));
210    let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default();
211    match learn::engineer(&cwd) {
212        e if e.is_empty() => println!("git: no user.email; lessons will name no author (git config --global user.email ...)"),
213        e => println!("git: you are {e}"),
214    }
215    let path = collective::config_path();
216    if path.exists() {
217        println!("config: {} exists; setup only adds to it", path.display());
218    } else {
219        println!("config: none yet at {}", path.display());
220    }
221    found
222}
223
224/// The harnesses to wire: `--harness` (an unknown name is an error), else the answer (asked again after
225/// an unknown one), by default the installed ones.
226fn harnesses(o: &Opts, ask: &mut Prompt, found: &[&'static dyn Harness]) -> Result<Vec<&'static dyn Harness>, String> {
227    if let Some(h) = &o.harness {
228        println!("wire borg into: {h}");
229        return pick(h);
230    }
231    let installed = found.iter().map(|h| h.name()).collect::<Vec<_>>().join(",");
232    let q = format!("Wire borg into ({}, all, or - for none)", harness::names(", "));
233    loop {
234        match pick(&ask.line(&q, if installed.is_empty() { "-" } else { &installed })) {
235            Err(e) if !ask.yes() => println!("{e}"),
236            picked => return picked,
237        }
238    }
239}
240
241/// The harnesses an answer names (`all`, names, `-`), each once.
242fn pick(answer: &str) -> Result<Vec<&'static dyn Harness>, String> {
243    let mut out: Vec<&'static dyn Harness> = Vec::new();
244    for w in prompt::split(answer) {
245        let hs: Vec<_> = if w == "all" { harness::visible().collect() } else { vec![harness::by_name(&w).ok_or(format!("unknown harness {w:?}"))?] };
246        out.extend(hs.into_iter().filter(|h| !out.iter().any(|o| o.name() == h.name())).collect::<Vec<_>>());
247    }
248    Ok(out)
249}
250
251/// Step 3: each harness's install plan (hooks or adapter, and the skill); its notes printed now.
252fn wiring(hs: &[&'static dyn Harness], t: &Targets) -> Result<Vec<Wire>, String> {
253    let bin = install::bin();
254    let mut out = Vec::new();
255    for &h in hs {
256        let (changes, notes) = install::plan(Action::Install, h, t, &bin).map_err(|e| format!("{}: {e}", h.name()))?;
257        for n in &notes {
258            println!("{}: {n}", h.name());
259        }
260        if changes.is_empty() {
261            println!("{}: already wired", h.name());
262        } else {
263            out.push((h, changes));
264        }
265    }
266    Ok(out)
267}
268
269/// Prints the plan; whether there is anything to do. Files that are borg's own text (the skill's, a new
270/// adapter) get one line each, not their diff (`borg install` shows it); the engineer's files get a diff.
271fn show(p: &Plan, t: &Targets) -> bool {
272    p.config.iter().for_each(|c| print!("{}", install::diff(&c.path, c.before.as_deref(), c.after.as_deref())));
273    for (h, changes) in &p.wiring {
274        let skill = t.skill_dir(*h);
275        for c in changes {
276            if own(*h, c, skill.as_deref()) {
277                let n = c.after.as_deref().map_or(0, |a| a.lines().count());
278                println!("{}: {} ({n} lines of borg's own; `borg install {}` shows them)", h.name(), c.path.display(), h.name());
279            } else {
280                print!("{}", install::diff(&c.path, c.before.as_deref(), c.after.as_deref()));
281            }
282        }
283    }
284    if let Some(m) = &p.mesh {
285        println!("then: borg mesh {}", m.args().join(" "));
286    }
287    p.vectors.iter().for_each(|v| println!("then: {v}"));
288    p.config.is_some() || !p.wiring.is_empty() || p.mesh.is_some() || p.vectors.is_some()
289}
290
291/// Whether `c` writes borg's own compiled-in text: a skill file, or an adapter where there was none.
292fn own(h: &dyn Harness, c: &Change, skill: Option<&Path>) -> bool {
293    skill.is_some_and(|d| c.path.starts_with(d)) || (c.before.is_none() && c.after.as_deref() == Some(h.wiring().adapter()))
294}
295
296/// Step 5: `borg doctor`, the WARNs of the steps above, what to do next. Doctor's exit code.
297fn verify(o: &Opts, warns: &[String], mesh: Option<&Mesh>) -> i32 {
298    let home: Vec<&str> = o.home.as_deref().map(|h| vec!["--home", h]).unwrap_or_default();
299    let code = install::main("doctor", &home);
300    for w in warns {
301        println!("WARN {w}");
302    }
303    println!("\nnext:\n  borg status                 what each collective holds\n  borg collective [DIR]       which collective a repo belongs to");
304    match mesh {
305        Some(Mesh::Init(name)) => {
306            println!("  borg mesh serve --collective {name}   keep it running (a service); then `borg mesh invite --collective {name}`");
307        }
308        Some(Mesh::Join { .. }) => println!("  borg mesh serve             keep it running inside one of the team's repos"),
309        None => {}
310    }
311    code
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn flags_parse_and_conflicts_are_refused() {
320        let o = parse(&["--yes", "--private", "~/a", "~/b", "--team", "corp", "--paths", "~/w", "--relay", "disabled", "--no-vectors"]).unwrap();
321        assert!(o.yes && o.no_vectors);
322        assert_eq!(o.private.as_deref(), Some(&["~/a".to_string(), "~/b".to_string()][..]));
323        assert_eq!((o.team.as_deref(), o.paths.as_slice(), o.relay.as_deref()), (Some("corp"), &["~/w".to_string()][..], Some("disabled")));
324        assert!(parse(&["--team", "a", "--join", "BORG1X"]).is_err());
325        assert!(parse(&["--paths", "~/w"]).is_err());
326        assert!(parse(&["--join", "BORG1X", "--relay", "disabled"]).is_err());
327        assert!(parse(&["--team", "a", "--relay", "relay.corp"]).is_err());
328        assert!(parse(&["--yes", "--team", "a"]).unwrap_err().contains("--paths"), "--yes founds no team without repos");
329        assert!(parse(&["--join", "notaticket"]).unwrap_err().contains("BORG1"));
330        assert!(parse(&["--team"]).unwrap_err().contains("needs a value"));
331        assert!(parse(&["--bogus"]).unwrap_err().contains("unknown flag"));
332    }
333
334    #[test]
335    fn every_listed_flag_parses() {
336        for f in FLAGS {
337            let rest: &[&str] = if matches!(*f, "--yes" | "--no-vectors") { &[] } else { &["x"] };
338            let mut o = Opts::default();
339            assert!(o.set(f, rest).is_ok(), "{f}");
340        }
341    }
342}