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