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::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
24const DEFAULT_CONFIG: &str = "~/.config/beady-eye/config.toml";
26
27const PROJECT_IN_THE_ENVIRONMENT: &str = "BDI_PROJECT";
30
31const NO_TERMINAL: u8 = 2;
34
35#[derive(Parser)]
36#[command(name = "bdi", version, about = "A tree of work in flight")]
37struct Cli {
38 #[arg(value_name = "BEAD-ID")]
43 beads: Vec<String>,
44
45 #[arg(long)]
48 config: Option<String>,
49
50 #[arg(long = "project", value_name = "NAME")]
54 projects: Vec<String>,
55
56 #[arg(long = "all-projects", conflicts_with = "projects")]
60 all_projects: bool,
61
62 #[arg(long)]
64 json: bool,
65
66 #[arg(long)]
68 all: bool,
69
70 #[arg(long, conflicts_with = "no_poll")]
72 poll: bool,
73
74 #[arg(long = "no-poll")]
76 no_poll: bool,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum Polling {
89 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 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#[derive(Debug, Clone, PartialEq, Eq)]
119enum Reading {
120 WhereBdiWasStarted,
123 EveryProject,
125 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
141struct Launch<'a> {
144 cwd: &'a Path,
145 reading: Reading,
146 roots: &'a [String],
147}
148
149#[derive(Debug)]
157struct Settled {
158 config: Config,
159 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 let started_on = cfg.clone();
212 let polling = Polling::asked_for(&cli);
213 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 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 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 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
285fn 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
299fn 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
324fn 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
336fn 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 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
383fn 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 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 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 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 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 fn directories_entered(runner: &FakeRunner) -> Vec<Option<PathBuf>> {
468 runner.calls().into_iter().map(|c| c.cwd).collect()
469 }
470
471 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 fn two_repositories() -> FakeRunner {
510 FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
511 }
512
513 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}