Skip to main content

beady_eye/
cli.rs

1//! What `bdi` does when it is run: the arguments it takes, the config those
2//! arguments resolve against, and whether the snapshot is drawn or printed.
3
4use std::io::{ErrorKind, IsTerminal};
5use std::path::{Path, PathBuf};
6use std::process::ExitCode;
7use std::sync::Arc;
8use std::time::Duration;
9
10use anyhow::Context;
11use chrono::Utc;
12use clap::Parser;
13
14use crate::app::Asked;
15use crate::collect::agents::Agents;
16use crate::collect::bd;
17use crate::collect::discovery;
18use crate::collect::herdr;
19use crate::collect::run::{RealRunner, Runner};
20use crate::config::Config;
21use crate::model::snapshot::Filter;
22use crate::tui::{Armed, Arming, Reload, CHECKED_EVERY};
23
24/// Where the config lives when nothing says otherwise.
25const DEFAULT_CONFIG: &str = "~/.config/beady-eye/config.toml";
26
27/// The variable that names the project when no config file names one, ahead
28/// of the name its repository or directory would give it.
29const PROJECT_IN_THE_ENVIRONMENT: &str = "BDI_PROJECT";
30
31/// The view is drawn on the alternate screen, so a `bdi` whose output is a
32/// pipe has nowhere to draw and `--json` is the only thing it can give.
33const NO_TERMINAL: u8 = 2;
34
35#[derive(Parser)]
36#[command(name = "bdi", version, about = "A tree of work in flight")]
37struct Cli {
38    /// Draw the tree this bead roots, alongside the trees bdi discovers.
39    /// Write it as <project>:<bead-id> where bdi is reading more than one
40    /// project; a bare id means the one project being read. A root under a
41    /// project the directory left out reads that project too.
42    #[arg(value_name = "BEAD-ID")]
43    beads: Vec<String>,
44
45    /// Read the configuration from this file, rather than
46    /// ~/.config/beady-eye/config.toml.
47    #[arg(long)]
48    config: Option<String>,
49
50    /// Read only the projects named, repeating the option for each, from
51    /// wherever bdi is started. The ones left out are not read at all,
52    /// rather than read and hidden.
53    #[arg(long = "project", value_name = "NAME")]
54    projects: Vec<String>,
55
56    /// Read every configured project, wherever bdi is started. Without it,
57    /// bdi started under a configured project's directory reads that
58    /// project alone.
59    #[arg(long = "all-projects", conflicts_with = "projects")]
60    all_projects: bool,
61
62    /// Emit the snapshot as JSON.
63    #[arg(long)]
64    json: bool,
65
66    /// Draw every tree, including those with no live agent.
67    #[arg(long)]
68    all: bool,
69
70    /// Poll every project this run, whatever the config says about each.
71    #[arg(long, conflicts_with = "no_poll")]
72    poll: bool,
73
74    /// Poll no project this run, whatever the config says about each.
75    #[arg(long = "no-poll")]
76    no_poll: bool,
77}
78
79/// What this run said about polling, over what its config says about each
80/// project.
81///
82/// A run-level switch rather than a second way of naming projects: `--project`
83/// already narrows the set, and what this is for is bisecting — turning the
84/// poll on to see whether a suspect producer was the only thing wrong, or off
85/// to see whether it was working at all — on a machine nobody wants to
86/// redeploy to find out.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum Polling {
89    /// Each project as its own `poll` key says.
90    AsConfigured,
91    Everything,
92    Nothing,
93}
94
95impl Polling {
96    fn asked_for(cli: &Cli) -> Self {
97        match (cli.poll, cli.no_poll) {
98            (true, _) => Polling::Everything,
99            (_, true) => Polling::Nothing,
100            _ => Polling::AsConfigured,
101        }
102    }
103
104    /// How long after a read this project waits before asking for the next,
105    /// or nothing where it does not ask at all.
106    fn after_a_read(self, project: &crate::config::Project, every: Duration) -> Option<Duration> {
107        let polls = match self {
108            Polling::AsConfigured => project.poll,
109            Polling::Everything => true,
110            Polling::Nothing => false,
111        };
112        polls.then_some(every)
113    }
114}
115
116/// Which of the configured projects this run reads, as the command line
117/// said.
118#[derive(Debug, Clone, PartialEq, Eq)]
119enum Reading {
120    /// Nothing said: the project holding the directory `bdi` was started in,
121    /// or every project where none holds it.
122    WhereBdiWasStarted,
123    /// `--all-projects`.
124    EveryProject,
125    /// `--project`, once for each.
126    Named(Vec<String>),
127}
128
129impl Reading {
130    fn asked_for(cli: &Cli) -> Self {
131        if cli.all_projects {
132            Reading::EveryProject
133        } else if cli.projects.is_empty() {
134            Reading::WhereBdiWasStarted
135        } else {
136            Reading::Named(cli.projects.clone())
137        }
138    }
139}
140
141/// Where `bdi` was started and what its command line said — which, with the
142/// config, is everything the read set is a function of.
143struct Launch<'a> {
144    cwd: &'a Path,
145    reading: Reading,
146    roots: &'a [String],
147}
148
149/// The config this run works to, and the file it came out of.
150///
151/// What settling the config left `bdi` unable to do travels on the config
152/// itself rather than beside it, as a fact for the model to publish and the
153/// view to make words of. That is what keeps the two mouths saying the same
154/// thing: a fact carried beside the config reaches whichever of them the
155/// caller hands it to, and only the screen is ever handed anything.
156#[derive(Debug)]
157struct Settled {
158    config: Config,
159    /// The config file to watch for edits, or nothing where none was read.
160    read_from: Option<PathBuf>,
161}
162
163pub fn run() -> anyhow::Result<ExitCode> {
164    let cli = Cli::parse();
165
166    let home = std::env::var_os("HOME").map(PathBuf::from);
167    let cwd = std::env::current_dir().context("finding the current directory")?;
168    let launch = Launch {
169        cwd: &cwd,
170        reading: Reading::asked_for(&cli),
171        roots: &cli.beads,
172    };
173    let Settled {
174        config: mut cfg,
175        read_from,
176    } = match &cli.config {
177        Some(named) => read_config(&RealRunner, &expand_tilde(named, home), &launch),
178        None => config_for_wherever_bdi_was_run(
179            &RealRunner,
180            &expand_tilde(DEFAULT_CONFIG, home),
181            &launch,
182        ),
183    }?;
184
185    let filter = if cli.all {
186        Filter::All
187    } else {
188        Filter::LiveAgents
189    };
190    if cli.json {
191        let snapshot = crate::app::run(
192            &cfg,
193            &herdr::Herdr::new(&RealRunner as &dyn Runner),
194            &bd::Cli::new(&RealRunner),
195            filter,
196            Utc::now(),
197        );
198        println!("{}", serde_json::to_string_pretty(&snapshot)?);
199        return Ok(ExitCode::SUCCESS);
200    }
201
202    if !std::io::stdout().is_terminal() {
203        eprintln!("bdi's view needs a terminal; re-run with --json");
204        return Ok(ExitCode::from(NO_TERMINAL));
205    }
206
207    // The config the run starts on, kept back for the loop to be set up
208    // from. The collector takes the other copy below and works to whatever
209    // the reader writes after it, so this is the one that says what the
210    // first frame draws.
211    let started_on = cfg.clone();
212    let polling = Polling::asked_for(&cli);
213    // Asked again whenever the reader writes a config, so the set of
214    // projects that poll is the set the file names. The command line is what
215    // it carries that a config cannot: `--poll` and `--no-poll` overrule
216    // every project's own key, and they are settled here.
217    let arms: Arming = Box::new(move |cfg: &Config| {
218        cfg.read()
219            .map(|project| {
220                Armed::polling(
221                    project.name.clone(),
222                    polling.after_a_read(project, cfg.tui.refresh()),
223                )
224            })
225            .collect()
226    });
227    // Built before the config goes to the collector, and holding a copy of
228    // it: what a re-read is compared against is the config this run is
229    // working to, and after this line the collector owns the only other one.
230    //
231    // The file is read on the loop's own thread and what a re-read produces
232    // goes behind the collector's seam — `drive::looked_at` is where that
233    // split is decided and why.
234    let reload = read_from.map(|path| {
235        let cwd = cwd.clone();
236        let reading = Reading::asked_for(&cli);
237        let roots = cli.beads.clone();
238        Reload::watching(
239            path,
240            CHECKED_EVERY,
241            cfg.clone(),
242            Box::new(move |text| {
243                config_for_this_run(
244                    text,
245                    &RealRunner,
246                    &Launch {
247                        cwd: &cwd,
248                        reading: reading.clone(),
249                        roots: &roots,
250                    },
251                )
252            }),
253            Utc::now(),
254        )
255    });
256    let mut collection = crate::app::Collection::default();
257    let trackers = bd::Cli::new(&RealRunner);
258    // One provider for the run, asked by the collection on its thread and by
259    // the tail on another. Which one it is is chosen here and nowhere below.
260    let agents: Arc<dyn Agents> = Arc::new(herdr::Herdr::new(&RealRunner as &dyn Runner));
261    let listing = Arc::clone(&agents);
262    crate::tui::run(
263        &started_on,
264        filter,
265        arms,
266        agents,
267        Box::new(move |asked| match asked {
268            // Nothing is drawn for a config the reader has written: what it
269            // changes is what every read after it reads, and the loop asks
270            // for one of those behind it.
271            Asked::Reloaded(written) => {
272                cfg = *written;
273                None
274            }
275            Asked::Read(wanted) => {
276                Some(collection.collect(&cfg, &listing, &trackers, &wanted, filter, Utc::now()))
277            }
278        }),
279        reload,
280    )?;
281
282    Ok(ExitCode::SUCCESS)
283}
284
285/// A path the user named is read as written: a config that is not there is an
286/// error, never a reason to look somewhere else.
287///
288/// The path comes back beside the config because it is the file the run goes
289/// on looking at, and this is where which file that is gets settled.
290fn read_config(runner: &dyn Runner, path: &Path, launch: &Launch<'_>) -> anyhow::Result<Settled> {
291    let text = std::fs::read_to_string(path)
292        .with_context(|| format!("reading the config at {}", path.display()))?;
293    Ok(Settled {
294        config: config_for_this_run(&text, runner, launch)?,
295        read_from: Some(path.to_path_buf()),
296    })
297}
298
299/// A config file's text, as the config this run reads: scoped, holding the
300/// roots the command line names, and with each project read knowing its
301/// working trees. The file says where a project is; git says where else the
302/// same project is, because a seat working in a linked worktree is working
303/// in the project.
304///
305/// In that order. The scope is settled before git is asked where each
306/// project is worked, because asking is a `git worktree list` in the
307/// project's own directory — a project the run left out would otherwise
308/// still be gone to, which is the gathering scoping exists to avoid. The
309/// roots come between, because one under a project the directory left out
310/// widens the scope, and the project it brought in is asked like any other.
311/// The whole config is parsed first either way: what `[roots.explicit]` is
312/// checked against is the config as written, not the part of it this run
313/// reads.
314fn config_for_this_run(
315    text: &str,
316    runner: &dyn Runner,
317    launch: &Launch<'_>,
318) -> anyhow::Result<Config> {
319    let cfg = scoped(Config::from_toml(text)?, runner, launch)?
320        .with_roots_named_on_the_command_line(launch.roots)?;
321    Ok(discovery::with_the_working_trees_git_lists(cfg, runner))
322}
323
324/// The config scoped as the command line said, or as the directory says
325/// where it said nothing.
326fn scoped(cfg: Config, runner: &dyn Runner, launch: &Launch<'_>) -> anyhow::Result<Config> {
327    match &launch.reading {
328        Reading::Named(names) => cfg.scoped_to(names),
329        Reading::EveryProject => Ok(cfg),
330        Reading::WhereBdiWasStarted => {
331            Ok(discovery::scoped_to_the_directory(cfg, runner, launch.cwd))
332        }
333    }
334}
335
336/// The config, or — where there is no config file at all — the repository the
337/// current directory sits in. Only an absent file falls back; one that is
338/// there and will not open is still an error.
339///
340/// The one project discovery finds is everything there is, so the directory
341/// has nothing to choose between and the run reads it as everything: a
342/// `--project` naming something else is still refused, and a `--project`
343/// naming it is still obeyed.
344fn config_for_wherever_bdi_was_run(
345    runner: &dyn Runner,
346    path: &Path,
347    launch: &Launch<'_>,
348) -> anyhow::Result<Settled> {
349    match std::fs::read_to_string(path) {
350        Ok(text) => Ok(Settled {
351            config: config_for_this_run(&text, runner, launch)?,
352            read_from: Some(path.to_path_buf()),
353        }),
354        Err(absent) if absent.kind() == ErrorKind::NotFound => {
355            let named = std::env::var(PROJECT_IN_THE_ENVIRONMENT).ok();
356            let discovered =
357                discovery::from_the_current_directory(runner, launch.cwd, named.as_deref())
358                    .with_context(|| {
359                        format!(
360                            "there is no config at {}, so bdi read the current directory",
361                            path.display()
362                        )
363                    })?;
364            let cfg = match &launch.reading {
365                Reading::Named(names) => discovered.scoped_to(names)?,
366                Reading::EveryProject | Reading::WhereBdiWasStarted => discovered,
367            };
368            // No file, so nothing to look at again. A config file written
369            // while this run is going is a config file this run never read,
370            // and what it says about scope, roots and where each project is
371            // worked was settled against a directory rather than against it.
372            Ok(Settled {
373                config: cfg.with_roots_named_on_the_command_line(launch.roots)?,
374                read_from: None,
375            })
376        }
377        Err(unreadable) => {
378            Err(unreadable).with_context(|| format!("reading the config at {}", path.display()))
379        }
380    }
381}
382
383/// `~` belongs to the shell, so a config path written with one is expanded
384/// here rather than handed to the filesystem verbatim.
385fn expand_tilde(path: &str, home: Option<PathBuf>) -> PathBuf {
386    match (path.strip_prefix("~/"), home) {
387        (Some(rest), Some(home)) => home.join(rest),
388        _ => PathBuf::from(path),
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::collect::run::testing::FakeRunner;
396    use crate::collect::run::{Env, RunFailure};
397    use crate::config::Scope;
398    use clap::CommandFactory;
399    use std::collections::BTreeMap;
400
401    /// A config file naming one project, and git's answer for where that
402    /// project's repository is worked in.
403    const ONE_PROJECT: &str = r#"
404[[projects]]
405name = "orbital"
406path = "/srv/work/orbital"
407"#;
408
409    const TWO_PROJECTS: &str = r#"
410[[projects]]
411name = "orbital"
412path = "/srv/work/orbital"
413
414[[projects]]
415name = "ferry"
416path = "/srv/work/ferry"
417"#;
418
419    const A_WORKTREE_PER_SEAT: &str = "\
420worktree /srv/work/orbital
421HEAD 4d3c1f0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d
422branch refs/heads/main
423
424worktree /tmp/seat-a/wt
425HEAD 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
426detached
427";
428
429    /// A config file of our own, written where the test can hand its path to
430    /// the thing that reads it.
431    fn a_config_file(named: &str) -> PathBuf {
432        a_config_file_holding(named, ONE_PROJECT)
433    }
434
435    fn a_config_file_holding(named: &str, text: &str) -> PathBuf {
436        let path = std::env::temp_dir().join(format!("bdi-{named}-{}.toml", std::process::id()));
437        std::fs::write(&path, text).expect("the file is ours to write");
438        path
439    }
440
441    /// A run started in `cwd` with no flag about which projects to read and
442    /// no root named, which is how one `bdi` per desktop is started.
443    fn started_in(cwd: &'static str) -> Launch<'static> {
444        Launch {
445            cwd: Path::new(cwd),
446            reading: Reading::WhereBdiWasStarted,
447            roots: &[],
448        }
449    }
450
451    /// A run started somewhere no configured project holds, asking for
452    /// every project, for the tests that are not about scoping.
453    fn every_project() -> Launch<'static> {
454        Launch {
455            reading: Reading::EveryProject,
456            ..started_in("/home/elsewhere")
457        }
458    }
459
460    fn read_by(cfg: &Config) -> Vec<&str> {
461        cfg.read().map(|p| p.name.as_str()).collect()
462    }
463
464    /// The directory each call was made in. The argv is the same line
465    /// whichever project it is asked about, so the directory is what says
466    /// which project a call was for.
467    fn directories_entered(runner: &FakeRunner) -> Vec<Option<PathBuf>> {
468        runner.calls().into_iter().map(|c| c.cwd).collect()
469    }
470
471    /// A runner that fails the test on any call made under a directory the
472    /// scope left out. Not entering an excluded project is the property
473    /// scoping exists for, so it is measured on every call rather than read
474    /// back off the ones a test thought to look for.
475    struct NeverEntering {
476        forbidden: PathBuf,
477        inner: FakeRunner,
478    }
479
480    fn never_entering(forbidden: &str, inner: FakeRunner) -> NeverEntering {
481        NeverEntering {
482            forbidden: PathBuf::from(forbidden),
483            inner,
484        }
485    }
486
487    impl Runner for NeverEntering {
488        fn run(
489            &self,
490            program: &str,
491            args: &[&str],
492            cwd: Option<&Path>,
493            env: &Env,
494        ) -> Result<String, RunFailure> {
495            if let Some(entered) = cwd.filter(|cwd| cwd.starts_with(&self.forbidden)) {
496                panic!(
497                    "`{program} {}` was run in {}, which the scope left out",
498                    args.join(" "),
499                    entered.display()
500                );
501            }
502            self.inner.run(program, args, cwd, env)
503        }
504    }
505
506    /// git as a machine with two projects checked out answers it: orbital
507    /// worked in its checkout and a seat's worktree, ferry in its checkout
508    /// alone.
509    fn two_repositories() -> FakeRunner {
510        FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
511    }
512
513    // ---- the directory decides the read set --------------------------------
514
515    /// One `bdi` per desktop: started under one of the configured projects
516    /// it reads that project, and nothing goes near the other.
517    #[test]
518    fn bdi_started_under_a_configured_project_reads_that_project_alone() {
519        let path = a_config_file_holding("started-in-orbital", TWO_PROJECTS);
520        let runner = never_entering("/srv/work/ferry", two_repositories());
521
522        let cfg = read_config(&runner, &path, &started_in("/srv/work/orbital/src"))
523            .expect("the config is ours to read")
524            .config;
525
526        assert_eq!(read_by(&cfg), ["orbital"]);
527        assert_eq!(
528            cfg.scope,
529            Scope::Directory {
530                project: "orbital".to_string(),
531                widened: Vec::new(),
532            }
533        );
534        assert!(
535            cfg.projects[0]
536                .holds(Path::new("/tmp/seat-a/wt/src"))
537                .is_some(),
538            "the project read still learns its working trees"
539        );
540
541        std::fs::remove_file(&path).expect("the file is ours to remove");
542    }
543
544    /// A project may be a directory holding several repositories — a desktop
545    /// of them — and a `bdi` started in any one of those is in the project.
546    #[test]
547    fn bdi_started_in_a_repository_inside_a_project_reads_that_project() {
548        let path = a_config_file_holding("started-inside", TWO_PROJECTS);
549        let runner = never_entering("/srv/work/ferry", two_repositories());
550
551        let cfg = read_config(
552            &runner,
553            &path,
554            &started_in("/srv/work/orbital/ground-station/src"),
555        )
556        .expect("the config is ours to read")
557        .config;
558
559        assert_eq!(read_by(&cfg), ["orbital"]);
560
561        std::fs::remove_file(&path).expect("the file is ours to remove");
562    }
563
564    /// A seat works in a linked worktree outside the project's tree, and the
565    /// scope is decided before any project has been asked where it is
566    /// worked. One `git worktree list` from the directory itself names the
567    /// checkout it was cut from, and that is under the project.
568    #[test]
569    fn bdi_started_in_a_linked_worktree_of_a_project_reads_that_project() {
570        let path = a_config_file_holding("started-in-a-worktree", TWO_PROJECTS);
571        let runner = never_entering("/srv/work/ferry", two_repositories());
572
573        let cfg = read_config(&runner, &path, &started_in("/tmp/seat-a/wt/src"))
574            .expect("the config is ours to read")
575            .config;
576
577        assert_eq!(read_by(&cfg), ["orbital"]);
578        let from_the_directory: Vec<String> = runner
579            .inner
580            .calls()
581            .into_iter()
582            .filter(|c| c.cwd.as_deref() == Some(Path::new("/tmp/seat-a/wt/src")))
583            .map(|c| c.argv)
584            .collect();
585        assert_eq!(
586            from_the_directory,
587            ["git worktree list --porcelain"],
588            "one git call from the directory, and nothing else runs there"
589        );
590
591        std::fs::remove_file(&path).expect("the file is ours to remove");
592    }
593
594    /// Started outside every configured project there is nothing to scope
595    /// to, and nothing was asked for: `bdi` reads everything, as it did
596    /// before the directory had a say.
597    #[test]
598    fn bdi_started_outside_every_configured_project_reads_all_of_them() {
599        let path = a_config_file_holding("started-elsewhere", TWO_PROJECTS);
600        let runner = FakeRunner::default().with(
601            "git worktree list --porcelain",
602            "worktree /home/elsewhere\nHEAD 4d3c1f0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d\nbranch refs/heads/main\n",
603        );
604
605        let cfg = read_config(&runner, &path, &started_in("/home/elsewhere/notes"))
606            .expect("the config is ours to read")
607            .config;
608
609        assert_eq!(read_by(&cfg), ["orbital", "ferry"]);
610        assert_eq!(cfg.scope, Scope::Everything);
611
612        std::fs::remove_file(&path).expect("the file is ours to remove");
613    }
614
615    /// `--all-projects` is the opt-out: the session watching everything from
616    /// one project's checkout runs it, and the directory is not consulted.
617    #[test]
618    fn all_projects_reads_every_configured_project_wherever_bdi_was_started() {
619        let path = a_config_file_holding("all-projects", TWO_PROJECTS);
620        let runner = two_repositories();
621
622        let cfg = read_config(
623            &runner,
624            &path,
625            &Launch {
626                reading: Reading::EveryProject,
627                ..started_in("/srv/work/orbital/src")
628            },
629        )
630        .expect("the config is ours to read")
631        .config;
632
633        assert_eq!(read_by(&cfg), ["orbital", "ferry"]);
634        assert_eq!(cfg.scope, Scope::Everything);
635        assert!(
636            !directories_entered(&runner).contains(&Some(PathBuf::from("/srv/work/orbital/src"))),
637            "the directory was consulted when the command line had already decided"
638        );
639
640        std::fs::remove_file(&path).expect("the file is ours to remove");
641    }
642
643    /// `--project` stays the way to ask for a different project, or two,
644    /// from anywhere: it outranks the directory, and the directory is not
645    /// consulted.
646    #[test]
647    fn an_explicit_project_outranks_the_directory() {
648        let path = a_config_file_holding("project-outranks", TWO_PROJECTS);
649        let runner = never_entering("/srv/work/orbital", two_repositories());
650
651        let cfg = read_config(
652            &runner,
653            &path,
654            &Launch {
655                reading: Reading::Named(vec!["ferry".to_string()]),
656                ..started_in("/srv/work/orbital/src")
657            },
658        )
659        .expect("the config is ours to read")
660        .config;
661
662        assert_eq!(read_by(&cfg), ["ferry"]);
663        assert_eq!(cfg.scope, Scope::Asked(vec!["ferry".to_string()]));
664
665        std::fs::remove_file(&path).expect("the file is ours to remove");
666    }
667
668    /// `bdi ferry:fer-1` from orbital's desktop reads both. The widening
669    /// happens before git is asked where each project is worked, so the
670    /// project a root brought in learns its working trees like any other.
671    #[test]
672    fn a_root_under_a_project_the_directory_left_out_widens_the_read_set() {
673        let path = a_config_file_holding("root-widens", TWO_PROJECTS);
674        let runner = two_repositories();
675
676        let cfg = read_config(
677            &runner,
678            &path,
679            &Launch {
680                roots: &["ferry:fer-1".to_string()],
681                ..started_in("/srv/work/orbital/src")
682            },
683        )
684        .expect("a root elsewhere widens a scope the directory chose")
685        .config;
686
687        assert_eq!(read_by(&cfg), ["orbital", "ferry"]);
688        assert_eq!(
689            cfg.roots.explicit,
690            BTreeMap::from([("ferry".to_string(), vec!["fer-1".to_string()])])
691        );
692        assert!(
693            directories_entered(&runner).contains(&Some(PathBuf::from("/srv/work/ferry"))),
694            "ferry is read now, so git was asked where it is worked"
695        );
696
697        std::fs::remove_file(&path).expect("the file is ours to remove");
698    }
699
700    /// The same root against a scope the reader typed is a contradiction
701    /// inside one command line, and stays refused.
702    #[test]
703    fn a_root_under_a_project_an_explicit_scope_left_out_is_still_refused() {
704        let path = a_config_file_holding("root-refused", TWO_PROJECTS);
705        let runner = never_entering("/srv/work/ferry", two_repositories());
706
707        let refused = read_config(
708            &runner,
709            &path,
710            &Launch {
711                reading: Reading::Named(vec!["orbital".to_string()]),
712                roots: &["ferry:fer-1".to_string()],
713                ..started_in("/srv/work/orbital/src")
714            },
715        )
716        .expect_err("asking for ferry's root and asking not to read ferry");
717
718        assert!(format!("{refused:#}").contains("ferry"), "got: {refused:#}");
719
720        std::fs::remove_file(&path).expect("the file is ours to remove");
721    }
722
723    /// A bare id belongs to the one project being read, so `bdi orb-7` from
724    /// orbital's checkout needs no project name however many the config
725    /// names.
726    #[test]
727    fn a_bare_root_belongs_to_the_project_the_directory_chose() {
728        let path = a_config_file_holding("bare-root", TWO_PROJECTS);
729        let runner = never_entering("/srv/work/ferry", two_repositories());
730
731        let cfg = read_config(
732            &runner,
733            &path,
734            &Launch {
735                roots: &["orb-7".to_string()],
736                ..started_in("/srv/work/orbital/src")
737            },
738        )
739        .expect("the directory leaves only orbital")
740        .config;
741
742        assert_eq!(
743            cfg.roots.explicit,
744            BTreeMap::from([("orbital".to_string(), vec!["orb-7".to_string()])])
745        );
746
747        std::fs::remove_file(&path).expect("the file is ours to remove");
748    }
749
750    /// The no-config run is unchanged: the one project it discovers is
751    /// everything there is, and the screen has nothing to say about a scope.
752    #[test]
753    fn a_run_with_no_config_file_reads_the_discovered_project_as_everything() {
754        let absent =
755            std::env::temp_dir().join(format!("bdi-absent-everything-{}.toml", std::process::id()));
756        let runner = FakeRunner::default()
757            .with("bd where --json", "{}")
758            .with("git rev-parse --show-toplevel", "/srv/work/orbital")
759            .with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
760            .with("git remote get-url origin", "git@host:owner/orbital.git");
761
762        let cfg =
763            config_for_wherever_bdi_was_run(&runner, &absent, &started_in("/srv/work/orbital/src"))
764                .expect("the directory is a project")
765                .config;
766
767        assert_eq!(read_by(&cfg), ["orbital"]);
768        assert_eq!(cfg.scope, Scope::Everything);
769    }
770
771    /// git, as far as reading a config needs it: the one listing, whatever
772    /// is asked and wherever it is asked from.
773    struct ARepositoryWorkedInTwoPlaces;
774
775    impl Runner for ARepositoryWorkedInTwoPlaces {
776        fn run(
777            &self,
778            _program: &str,
779            _args: &[&str],
780            _cwd: Option<&Path>,
781            _env: &Env,
782        ) -> Result<String, RunFailure> {
783            Ok(A_WORKTREE_PER_SEAT.to_string())
784        }
785    }
786
787    /// A config file is where a project is written down, and nothing written
788    /// down can say where the seats are. Both ways of reading one ask git.
789    #[test]
790    fn a_config_the_command_line_names_learns_its_working_trees() {
791        let path = a_config_file("named-config");
792
793        let cfg = read_config(&ARepositoryWorkedInTwoPlaces, &path, &every_project())
794            .expect("the config is ours to read")
795            .config;
796
797        assert_eq!(
798            cfg.projects[0].worktrees,
799            vec![
800                PathBuf::from("/srv/work/orbital"),
801                PathBuf::from("/tmp/seat-a/wt"),
802            ]
803        );
804
805        std::fs::remove_file(&path).expect("the file is ours to remove");
806    }
807
808    #[test]
809    fn the_config_found_where_bdi_looks_learns_its_working_trees() {
810        let path = a_config_file("default-config");
811
812        let cfg =
813            config_for_wherever_bdi_was_run(&ARepositoryWorkedInTwoPlaces, &path, &every_project())
814                .expect("the config is ours to read")
815                .config;
816
817        assert!(
818            cfg.projects[0]
819                .holds(Path::new("/tmp/seat-a/wt/src"))
820                .is_some(),
821            "a seat in a linked worktree is working in the project"
822        );
823
824        std::fs::remove_file(&path).expect("the file is ours to remove");
825    }
826
827    /// Only an absent config falls back. One that is there and will not open
828    /// is an error, because reading the current directory instead would answer
829    /// a question the user did not ask and look like it had answered theirs.
830    #[test]
831    fn a_config_that_will_not_open_is_an_error_rather_than_a_fallback() {
832        let a_directory = std::env::temp_dir();
833
834        let refused = config_for_wherever_bdi_was_run(
835            &ARepositoryWorkedInTwoPlaces,
836            &a_directory,
837            &every_project(),
838        )
839        .expect_err("a directory is not a config file");
840
841        assert!(
842            format!("{refused:#}")
843                .contains(&format!("reading the config at {}", a_directory.display())),
844            "a config that will not open should be reported as itself, not as \
845             a missing one; said: {refused:#}"
846        );
847    }
848
849    /// Asking git where a project is worked is a subprocess in that project's
850    /// own directory, so it is gathering like any other and a scope has to
851    /// come first. It did not: `git worktree list` ran in every configured
852    /// project's path before the scope was applied, which the collection
853    /// tests could not see because they are handed a config that has already
854    /// been through this.
855    #[test]
856    fn a_project_the_scope_left_out_is_not_even_asked_where_it_is_worked() {
857        let path = a_config_file_holding("scoped-worktrees", TWO_PROJECTS);
858        let runner =
859            FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
860
861        read_config(
862            &runner,
863            &path,
864            &Launch {
865                reading: Reading::Named(vec!["orbital".to_string()]),
866                ..every_project()
867            },
868        )
869        .expect("the config is ours to read");
870
871        let asked = directories_entered(&runner);
872        assert!(
873            !asked.contains(&Some(PathBuf::from("/srv/work/ferry"))),
874            "ferry was scoped out, so nothing should have gone to its directory; asked {asked:?}"
875        );
876        assert!(
877            asked.contains(&Some(PathBuf::from("/srv/work/orbital"))),
878            "orbital was scoped in, so git was asked where it is worked; asked {asked:?}"
879        );
880
881        std::fs::remove_file(&path).expect("the file is ours to remove");
882    }
883
884    /// A run with no config file at all still has a scope to honour. The
885    /// project it discovers is one project, so a scope naming a different one
886    /// selects nothing and is refused — rather than starting on the project
887    /// the reader did not ask for, which is what a scope the fallback ignored
888    /// would do.
889    #[test]
890    fn a_scope_naming_no_discovered_project_is_refused_with_no_config_file() {
891        let absent = std::env::temp_dir().join(format!("bdi-absent-{}.toml", std::process::id()));
892        let runner = FakeRunner::default()
893            .with("bd where --json", "{}")
894            .with("git rev-parse --show-toplevel", "/srv/work/orbital")
895            .with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
896            .with("git remote get-url origin", "git@host:owner/orbital.git");
897
898        let refused = config_for_wherever_bdi_was_run(
899            &runner,
900            &absent,
901            &Launch {
902                reading: Reading::Named(vec!["nothing-of-the-sort".to_string()]),
903                ..started_in("/srv/work/orbital")
904            },
905        )
906        .expect_err("the scope names no project the current directory is in");
907
908        assert!(
909            format!("{refused:#}").contains("nothing-of-the-sort"),
910            "got: {refused:#}"
911        );
912    }
913
914    #[test]
915    fn the_command_line_is_well_formed() {
916        Cli::command().debug_assert();
917    }
918
919    #[test]
920    fn no_config_is_named_unless_one_is_asked_for() {
921        assert_eq!(Cli::parse_from(["bdi"]).config, None);
922    }
923
924    #[test]
925    fn a_config_named_on_the_command_line_is_taken_as_written() {
926        let cli = Cli::parse_from(["bdi", "--config", "/etc/beady-eye.toml"]);
927
928        assert_eq!(cli.config.as_deref(), Some("/etc/beady-eye.toml"));
929    }
930
931    #[test]
932    fn bead_ids_are_taken_as_arguments() {
933        let cli = Cli::parse_from(["bdi", "orb-7", "ferry:fer-9", "--json"]);
934
935        assert_eq!(cli.beads, ["orb-7", "ferry:fer-9"]);
936        assert!(cli.json);
937    }
938
939    #[test]
940    fn the_projects_to_draw_are_taken_as_a_repeatable_option() {
941        let cli = Cli::parse_from(["bdi", "--project", "orbital", "--project", "ferry"]);
942
943        assert_eq!(cli.projects, ["orbital", "ferry"]);
944    }
945
946    #[test]
947    fn no_project_is_named_unless_one_is_asked_for() {
948        assert!(Cli::parse_from(["bdi"]).projects.is_empty());
949    }
950
951    /// The run says nothing about which projects to read unless it is asked
952    /// to, and the directory then decides.
953    #[test]
954    fn a_run_that_names_no_project_leaves_it_to_the_directory() {
955        assert_eq!(
956            Reading::asked_for(&Cli::parse_from(["bdi"])),
957            Reading::WhereBdiWasStarted
958        );
959    }
960
961    #[test]
962    fn a_run_can_ask_for_every_project_or_name_the_ones_it_wants() {
963        assert_eq!(
964            Reading::asked_for(&Cli::parse_from(["bdi", "--all-projects"])),
965            Reading::EveryProject
966        );
967        assert_eq!(
968            Reading::asked_for(&Cli::parse_from(["bdi", "--project", "ferry"])),
969            Reading::Named(vec!["ferry".to_string()])
970        );
971    }
972
973    /// Every project and only these is a command line that contradicts
974    /// itself, and clap says so rather than one of them quietly winning.
975    #[test]
976    fn a_run_cannot_ask_for_every_project_and_name_only_some() {
977        assert!(Cli::try_parse_from(["bdi", "--all-projects", "--project", "ferry"]).is_err());
978    }
979
980    /// `--all` is the view filter — every tree, including those with no
981    /// live agent — and keeps that meaning; reading every project is a
982    /// different flag.
983    #[test]
984    fn all_is_the_filter_and_not_the_read_set() {
985        let cli = Cli::parse_from(["bdi", "--all"]);
986
987        assert!(cli.all);
988        assert_eq!(Reading::asked_for(&cli), Reading::WhereBdiWasStarted);
989    }
990
991    /// The run says nothing about polling unless it is asked to, and each
992    /// project is then read as its own config key says.
993    #[test]
994    fn a_run_that_says_nothing_about_polling_leaves_it_to_the_config() {
995        assert_eq!(
996            Polling::asked_for(&Cli::parse_from(["bdi"])),
997            Polling::AsConfigured
998        );
999    }
1000
1001    #[test]
1002    fn a_run_can_turn_the_poll_on_or_off_for_every_project() {
1003        assert_eq!(
1004            Polling::asked_for(&Cli::parse_from(["bdi", "--poll"])),
1005            Polling::Everything
1006        );
1007        assert_eq!(
1008            Polling::asked_for(&Cli::parse_from(["bdi", "--no-poll"])),
1009            Polling::Nothing
1010        );
1011    }
1012
1013    /// Asking for both is a command line that contradicts itself, and clap
1014    /// says so rather than one of them quietly winning.
1015    #[test]
1016    fn a_run_cannot_ask_for_the_poll_and_against_it_at_once() {
1017        assert!(Cli::try_parse_from(["bdi", "--poll", "--no-poll"]).is_err());
1018    }
1019
1020    /// The interval each project's next ask is armed from, or nothing where
1021    /// it does not ask: what the run said, over what its config says.
1022    #[test]
1023    fn what_the_run_said_outranks_what_each_project_says() {
1024        let every = Duration::from_secs(30);
1025        let polled = a_project(true);
1026        let pushed = a_project(false);
1027
1028        assert_eq!(
1029            Polling::AsConfigured.after_a_read(&polled, every),
1030            Some(every)
1031        );
1032        assert_eq!(Polling::AsConfigured.after_a_read(&pushed, every), None);
1033        assert_eq!(
1034            Polling::Everything.after_a_read(&pushed, every),
1035            Some(every)
1036        );
1037        assert_eq!(Polling::Nothing.after_a_read(&polled, every), None);
1038    }
1039
1040    fn a_project(poll: bool) -> crate::config::Project {
1041        crate::config::Project {
1042            name: "orbital".to_string(),
1043            path: PathBuf::from("/srv/work/orbital"),
1044            environment_command: None,
1045            credential_command: None,
1046            poll,
1047            worktrees: Vec::new(),
1048        }
1049    }
1050
1051    #[test]
1052    fn a_leading_tilde_becomes_the_home_directory() {
1053        let path = expand_tilde(DEFAULT_CONFIG, Some(PathBuf::from("/home/pilot")));
1054
1055        assert_eq!(
1056            path,
1057            PathBuf::from("/home/pilot/.config/beady-eye/config.toml")
1058        );
1059    }
1060
1061    #[test]
1062    fn a_path_that_names_no_home_is_left_as_written() {
1063        for path in ["/etc/beady-eye.toml", "~elsewhere/config.toml"] {
1064            assert_eq!(
1065                expand_tilde(path, Some(PathBuf::from("/home/pilot"))),
1066                PathBuf::from(path)
1067            );
1068        }
1069    }
1070
1071    /// Without a `HOME` there is nothing to expand to, and a path we cannot
1072    /// resolve is better reported by the open that fails than guessed at.
1073    #[test]
1074    fn without_a_home_the_path_is_left_as_written() {
1075        assert_eq!(
1076            expand_tilde(DEFAULT_CONFIG, None),
1077            PathBuf::from(DEFAULT_CONFIG)
1078        );
1079    }
1080}