1use 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
25const DEFAULT_CONFIG: &str = "~/.config/beady-eye/config.toml";
27
28const PROJECT_IN_THE_ENVIRONMENT: &str = "BDI_PROJECT";
31
32const NO_TERMINAL: u8 = 2;
35
36#[derive(Parser)]
37#[command(name = "bdi", version, about = "A tree of work in flight")]
38struct Cli {
39 #[arg(value_name = "BEAD-ID")]
44 beads: Vec<String>,
45
46 #[arg(long)]
49 config: Option<String>,
50
51 #[arg(long = "project", value_name = "NAME")]
55 projects: Vec<String>,
56
57 #[arg(long = "all-projects", conflicts_with = "projects")]
61 all_projects: bool,
62
63 #[arg(long)]
65 json: bool,
66
67 #[arg(long)]
69 all: bool,
70
71 #[arg(long, conflicts_with = "no_poll")]
73 poll: bool,
74
75 #[arg(long = "no-poll")]
77 no_poll: bool,
78
79 #[arg(long, value_name = "PATH")]
84 socket: Option<PathBuf>,
85}
86
87fn told_to_listen_on(cli: &Cli, cfg: &Config) -> Option<PathBuf> {
96 cli.socket.clone().or_else(|| cfg.changes.socket.clone())
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum Polling {
109 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 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#[derive(Debug, Clone, PartialEq, Eq)]
139enum Reading {
140 WhereBdiWasStarted,
143 EveryProject,
145 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
161struct Launch<'a> {
164 cwd: &'a Path,
165 reading: Reading,
166 roots: &'a [String],
167}
168
169#[derive(Debug)]
177struct Settled {
178 config: Config,
179 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 let started_on = cfg.clone();
232 let listening_on = changes::where_writers_find_bdi(told_to_listen_on(&cli, &cfg));
236 let polling = Polling::asked_for(&cli);
237 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 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 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 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
310fn 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
324fn 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
349fn 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
361fn 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 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
408fn 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 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 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 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 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 fn directories_entered(runner: &FakeRunner) -> Vec<Option<PathBuf>> {
493 runner.calls().into_iter().map(|c| c.cwd).collect()
494 }
495
496 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 fn two_repositories() -> FakeRunner {
535 FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
536 }
537
538 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 worktrees: Vec::new(),
1118 }
1119 }
1120
1121 #[test]
1122 fn a_leading_tilde_becomes_the_home_directory() {
1123 let path = expand_tilde(DEFAULT_CONFIG, Some(PathBuf::from("/home/pilot")));
1124
1125 assert_eq!(
1126 path,
1127 PathBuf::from("/home/pilot/.config/beady-eye/config.toml")
1128 );
1129 }
1130
1131 #[test]
1132 fn a_path_that_names_no_home_is_left_as_written() {
1133 for path in ["/etc/beady-eye.toml", "~elsewhere/config.toml"] {
1134 assert_eq!(
1135 expand_tilde(path, Some(PathBuf::from("/home/pilot"))),
1136 PathBuf::from(path)
1137 );
1138 }
1139 }
1140
1141 #[test]
1144 fn without_a_home_the_path_is_left_as_written() {
1145 assert_eq!(
1146 expand_tilde(DEFAULT_CONFIG, None),
1147 PathBuf::from(DEFAULT_CONFIG)
1148 );
1149 }
1150}