swarmctl setup status shows progress at a glance
This commit is contained in:
parent
1c3b37e46a
commit
d55cf9ed80
6 changed files with 269 additions and 50 deletions
|
|
@ -18,7 +18,7 @@ wait for you. The sections below are the same steps by hand.
|
||||||
|
|
||||||
Run `swarmctl setup status` at any point to see how far you have got. It lists
|
Run `swarmctl setup status` at any point to see how far you have got. It lists
|
||||||
the hand steps below, numbered 1 to 10 in the order the sections run them, marks
|
the hand steps below, numbered 1 to 10 in the order the sections run them, marks
|
||||||
each done, not done or unknown, and names the next one. It changes nothing.
|
each done, not done or unknown, shows a progress bar, and names the next one. It changes nothing.
|
||||||
|
|
||||||
> **Not all-local?** Each step says which host it runs on. A split swarm
|
> **Not all-local?** Each step says which host it runs on. A split swarm
|
||||||
> also has credentials you can't generate where they're read — each
|
> also has credentials you can't generate where they're read — each
|
||||||
|
|
|
||||||
|
|
@ -658,7 +658,7 @@ For scripts, use `setup status` and the individual verbs.
|
||||||
|
|
||||||
###### **Subcommands:**
|
###### **Subcommands:**
|
||||||
|
|
||||||
* `status` — Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next
|
* `status` — Print the first-run setup as a progress report: a count and a bar, then each step as done, not done or unknown, and what to do next
|
||||||
|
|
||||||
###### **Options:**
|
###### **Options:**
|
||||||
|
|
||||||
|
|
@ -670,7 +670,7 @@ For scripts, use `setup status` and the individual verbs.
|
||||||
|
|
||||||
## `swarmctl setup status`
|
## `swarmctl setup status`
|
||||||
|
|
||||||
Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next.
|
Print the first-run setup as a progress report: a count and a bar, then each step as done, not done or unknown, and what to do next.
|
||||||
|
|
||||||
Read-only, and needs no token. The secret store's steps come from `bao status` and systemd, so they show only on the host running swarm-bao; the SSO account comes from authelia's users file, on its host; ruth and the other agents come from the swarm-controller. When a check can't run, its steps print `unknown` with the reason, and the rest still print. Steps 7 and 8, the browser login and the forge site admin, always print `unknown (not observable)`.
|
Read-only, and needs no token. The secret store's steps come from `bao status` and systemd, so they show only on the host running swarm-bao; the SSO account comes from authelia's users file, on its host; ruth and the other agents come from the swarm-controller. When a check can't run, its steps print `unknown` with the reason, and the rest still print. Steps 7 and 8, the browser login and the forge site admin, always print `unknown (not observable)`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -384,14 +384,14 @@ pkgs.testers.runNixOSTest (
|
||||||
# Ruth is declared paused and her `up` is a later step, so the report
|
# Ruth is declared paused and her `up` is a later step, so the report
|
||||||
# ends on step 9 not done and the exit status is 1.
|
# ends on step 9 not done and the exit status is 1.
|
||||||
reported = [
|
reported = [
|
||||||
" 1 secret store initialised: done",
|
"✅ 1 secret store initialised",
|
||||||
" 2 secret store unsealed: done",
|
"✅ 2 secret store unsealed",
|
||||||
" 3 granter bootstrapped: done",
|
"✅ 3 granter bootstrapped",
|
||||||
" 4 granting units active: done",
|
"✅ 4 granting units active",
|
||||||
" 5 store unsealed after the granting units: done",
|
"✅ 5 store unsealed after the granting units",
|
||||||
" 6 SSO account in the admins group: done",
|
"✅ 6 SSO account in the admins group",
|
||||||
" 9 ruth created and up: not done",
|
"👉 9 ruth created and up",
|
||||||
"10 another agent [optional]: done",
|
"✅ 10 another agent [optional]",
|
||||||
]
|
]
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
lines = machine.execute("swarmctl setup status")[1].splitlines()
|
lines = machine.execute("swarmctl setup status")[1].splitlines()
|
||||||
|
|
|
||||||
|
|
@ -240,14 +240,22 @@ and the verbs. The flow is `src/interactive.rs`; the terminal and side effects a
|
||||||
|
|
||||||
```console
|
```console
|
||||||
# swarmctl setup status
|
# swarmctl setup status
|
||||||
1 secret store initialised: done
|
🏗️ setup: 1/8 steps done █░░░░░░░░░
|
||||||
2 secret store unsealed: not done (the store is sealed)
|
|
||||||
|
✅ 1 secret store initialised
|
||||||
|
👉 2 secret store unsealed the store is sealed
|
||||||
|
↳ setup.md § 1: `bao operator unseal`
|
||||||
...
|
...
|
||||||
|
|
||||||
next: step 2, setup.md § 1: `bao operator unseal`
|
next: step 2, setup.md § 1: `bao operator unseal`
|
||||||
```
|
```
|
||||||
|
|
||||||
One line per step of [`docs/1-getting-started/setup.md`](../docs/1-getting-started/setup.md),
|
A header with the count and a bar, then one line per step of
|
||||||
`done`, `not done` or `unknown (<why>)`, and a pointer to the first step left.
|
[`docs/1-getting-started/setup.md`](../docs/1-getting-started/setup.md): `✅` done,
|
||||||
|
`⬜` not done, `❓` unknown with the reason. The first step left is marked `👉` with its hint, and
|
||||||
|
the last line is that hint, or `🎉 setup complete`. The count and bar cover the steps the host can
|
||||||
|
observe; failed checks show as `❓ N unknown` in the header. On a terminal, `checking <what>…`
|
||||||
|
shows on stderr while the checks run, and colour is on unless `NO_COLOR` is set; piped output is plain.
|
||||||
Read-only, and it needs no token. Where each step is read from:
|
Read-only, and it needs no token. Where each step is read from:
|
||||||
|
|
||||||
| steps | read from |
|
| steps | read from |
|
||||||
|
|
|
||||||
|
|
@ -295,8 +295,8 @@ struct SetupArgs {
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum SetupVerb {
|
enum SetupVerb {
|
||||||
/// Print each step of the first-run setup, in order, as `done`, `not done`
|
/// Print the first-run setup as a progress report: a count and a bar, then
|
||||||
/// or `unknown`, and what to do next.
|
/// each step as done, not done or unknown, and what to do next.
|
||||||
///
|
///
|
||||||
/// Read-only, and needs no token. The secret store's steps come from `bao
|
/// Read-only, and needs no token. The secret store's steps come from `bao
|
||||||
/// status` and systemd, so they show only on the host running swarm-bao; the
|
/// status` and systemd, so they show only on the host running swarm-bao; the
|
||||||
|
|
|
||||||
|
|
@ -314,30 +314,127 @@ pub(crate) fn complete(steps: &[Step]) -> bool {
|
||||||
.all(|s| s.optional || matches!(s.status, Status::Done(_) | Status::Unobservable(_)))
|
.all(|s| s.optional || matches!(s.status, Status::Done(_) | Status::Unobservable(_)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line per step, then what to do next.
|
const BAR_CELLS: usize = 10;
|
||||||
pub(crate) fn render(steps: &[Step]) -> String {
|
|
||||||
|
/// `done` of `total` as a bar of [`BAR_CELLS`] cells. Full only when `done`
|
||||||
|
/// equals `total`.
|
||||||
|
fn bar(done: usize, total: usize) -> String {
|
||||||
|
let filled = (done * BAR_CELLS).checked_div(total).unwrap_or(0);
|
||||||
|
format!("{}{}", "█".repeat(filled), "░".repeat(BAR_CELLS - filled))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SGR wrappers; plain text when colour is off.
|
||||||
|
struct Paint(bool);
|
||||||
|
|
||||||
|
impl Paint {
|
||||||
|
fn wrap(&self, code: &str, text: &str) -> String {
|
||||||
|
if self.0 {
|
||||||
|
format!("\x1b[{code}m{text}\x1b[0m")
|
||||||
|
} else {
|
||||||
|
text.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn green(&self, text: &str) -> String {
|
||||||
|
self.wrap("32", text)
|
||||||
|
}
|
||||||
|
fn yellow(&self, text: &str) -> String {
|
||||||
|
self.wrap("33", text)
|
||||||
|
}
|
||||||
|
fn dim(&self, text: &str) -> String {
|
||||||
|
self.wrap("2", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether stdout takes colour: a terminal, with `NO_COLOR` unset or empty.
|
||||||
|
fn color_enabled() -> bool {
|
||||||
|
use std::io::IsTerminal as _;
|
||||||
|
color_wanted(
|
||||||
|
std::io::stdout().is_terminal(),
|
||||||
|
std::env::var_os("NO_COLOR").as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_wanted(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
|
||||||
|
is_terminal && no_color.is_none_or(|v| v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The step report: a header with the count and a bar, one line per step
|
||||||
|
/// (the first one to do marked, with its hint under it), then what to do
|
||||||
|
/// next. `color` adds ANSI colour; the emoji are always there.
|
||||||
|
pub(crate) fn render(steps: &[Step], color: bool) -> String {
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
let mut out = String::new();
|
let paint = Paint(color);
|
||||||
for step in steps {
|
let observable = steps
|
||||||
let status = match &step.status {
|
|
||||||
Status::Done(d) if d.is_empty() => "done".to_owned(),
|
|
||||||
Status::Done(d) => format!("done ({d})"),
|
|
||||||
Status::NotDone(why) => format!("not done ({why})"),
|
|
||||||
Status::Unknown(why) => format!("unknown ({why})"),
|
|
||||||
Status::Unobservable(why) => format!("unknown (not observable: {why})"),
|
|
||||||
};
|
|
||||||
let optional = if step.optional { " [optional]" } else { "" };
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"{:>2} {}{optional}: {status}",
|
|
||||||
step.number, step.title
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(next) = steps
|
|
||||||
.iter()
|
.iter()
|
||||||
.find(|s| !s.optional && matches!(s.status, Status::NotDone(_)))
|
.filter(|s| !matches!(s.status, Status::Unobservable(_)))
|
||||||
{
|
.count();
|
||||||
|
let done = steps
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s.status, Status::Done(_)))
|
||||||
|
.count();
|
||||||
|
let unknown = steps
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s.status, Status::Unknown(_)))
|
||||||
|
.count();
|
||||||
|
let next = steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| !s.optional && matches!(s.status, Status::NotDone(_)));
|
||||||
|
|
||||||
|
let mut out = format!(
|
||||||
|
"🏗️ setup: {done}/{observable} steps done {}",
|
||||||
|
bar(done, observable)
|
||||||
|
);
|
||||||
|
if unknown > 0 {
|
||||||
|
let _ = write!(out, " ❓ {unknown} unknown");
|
||||||
|
}
|
||||||
|
out.push_str("\n\n");
|
||||||
|
|
||||||
|
let titles: Vec<String> = steps
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
if s.optional {
|
||||||
|
format!("{} [optional]", s.title)
|
||||||
|
} else {
|
||||||
|
s.title.to_owned()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let width = titles.iter().map(|t| t.chars().count()).max().unwrap_or(0);
|
||||||
|
for (step, title) in steps.iter().zip(&titles) {
|
||||||
|
let is_next = next.is_some_and(|n| n.number == step.number);
|
||||||
|
let (icon, note) = match &step.status {
|
||||||
|
Status::Done(d) => ("✅", d.clone()),
|
||||||
|
Status::NotDone(why) if is_next => ("👉", why.clone()),
|
||||||
|
Status::NotDone(why) => ("⬜", why.clone()),
|
||||||
|
Status::Unknown(why) => ("❓", format!("unknown ({why})")),
|
||||||
|
Status::Unobservable(why) => ("❓", format!("unknown (not observable: {why})")),
|
||||||
|
};
|
||||||
|
let label = if note.is_empty() {
|
||||||
|
format!("{:>2} {title}", step.number)
|
||||||
|
} else {
|
||||||
|
format!("{:>2} {title:<width$}", step.number)
|
||||||
|
};
|
||||||
|
let label = match &step.status {
|
||||||
|
Status::Done(_) => paint.green(&label),
|
||||||
|
Status::NotDone(_) => paint.yellow(&label),
|
||||||
|
_ => paint.dim(&label),
|
||||||
|
};
|
||||||
|
let note = if note.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {}", paint.dim(¬e))
|
||||||
|
};
|
||||||
|
let _ = writeln!(out, "{icon} {label}{note}");
|
||||||
|
if is_next {
|
||||||
|
let _ = writeln!(out, " ↳ {}", step.hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
if complete(steps) {
|
||||||
|
let _ = writeln!(out, "🎉 setup complete");
|
||||||
|
} else if let Some(next) = next {
|
||||||
let _ = writeln!(out, "next: step {}, {}", next.number, next.hint);
|
let _ = writeln!(out, "next: step {}, {}", next.number, next.hint);
|
||||||
} else if let Some(blind) = steps
|
} else if let Some(blind) = steps
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -355,11 +452,24 @@ pub(crate) fn render(steps: &[Step]) -> String {
|
||||||
/// Run `swarmctl setup status`. Returns whether the ceremony is [`complete`].
|
/// Run `swarmctl setup status`. Returns whether the ceremony is [`complete`].
|
||||||
///
|
///
|
||||||
/// `target` and `users_file` arrive as the results of resolving them, so a
|
/// `target` and `users_file` arrive as the results of resolving them, so a
|
||||||
/// host that lacks either still reports the steps that need neither.
|
/// host that lacks either still reports the steps that need neither. On a
|
||||||
|
/// terminal, a `checking <what>…` line on stderr names the running check
|
||||||
|
/// and is erased when the report prints; piped output carries only the report.
|
||||||
pub(crate) fn status(target: &Result<Target>, users_file: &Result<std::path::PathBuf>) -> bool {
|
pub(crate) fn status(target: &Result<Target>, users_file: &Result<std::path::PathBuf>) -> bool {
|
||||||
let observed = observe(target, users_file);
|
use std::io::{IsTerminal as _, Write as _};
|
||||||
|
|
||||||
|
let live = std::io::stderr().is_terminal();
|
||||||
|
let observed = observe_with(target, users_file, &mut |what| {
|
||||||
|
if live {
|
||||||
|
eprint!("\r\x1b[K⏳ checking {what}…");
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if live {
|
||||||
|
eprint!("\r\x1b[K");
|
||||||
|
}
|
||||||
let steps = classify(&observed);
|
let steps = classify(&observed);
|
||||||
print!("{}", render(&steps));
|
print!("{}", render(&steps, color_enabled()));
|
||||||
complete(&steps)
|
complete(&steps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -369,13 +479,27 @@ pub(crate) fn observe(
|
||||||
target: &Result<Target>,
|
target: &Result<Target>,
|
||||||
users_file: &Result<std::path::PathBuf>,
|
users_file: &Result<std::path::PathBuf>,
|
||||||
) -> Observed {
|
) -> Observed {
|
||||||
|
observe_with(target, users_file, &mut |_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`observe`], calling `progress` with a name before each check starts.
|
||||||
|
fn observe_with(
|
||||||
|
target: &Result<Target>,
|
||||||
|
users_file: &Result<std::path::PathBuf>,
|
||||||
|
progress: &mut dyn FnMut(&str),
|
||||||
|
) -> Observed {
|
||||||
|
progress("the secret store");
|
||||||
let store = bao_status();
|
let store = bao_status();
|
||||||
|
progress("the granter");
|
||||||
let granter = granter();
|
let granter = granter();
|
||||||
|
progress("the granting units");
|
||||||
let policy_units = policy_units();
|
let policy_units = policy_units();
|
||||||
|
progress("the admin account");
|
||||||
let admin_user = users_file
|
let admin_user = users_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_err(unset_reason)
|
.map_err(unset_reason)
|
||||||
.and_then(|path| has_admin_user(path).map_err(|e| format!("{e:#}")));
|
.and_then(|path| has_admin_user(path).map_err(|e| format!("{e:#}")));
|
||||||
|
progress("the agents");
|
||||||
let agents = target
|
let agents = target
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_err(unset_reason)
|
.map_err(unset_reason)
|
||||||
|
|
@ -779,7 +903,10 @@ mod tests {
|
||||||
let mut o = finished();
|
let mut o = finished();
|
||||||
o.agents = Ok(agents(vec![], None));
|
o.agents = Ok(agents(vec![], None));
|
||||||
assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("roster")));
|
assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("roster")));
|
||||||
o.agents = Ok(agents(vec![row("ruth", Some("paused"))], Some(Ok(true))));
|
o.agents = Ok(agents(
|
||||||
|
vec![row("ruth", Some("paused")), row("iris", Some("paused"))],
|
||||||
|
Some(Ok(true)),
|
||||||
|
));
|
||||||
assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("paused")));
|
assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("paused")));
|
||||||
o.agents = Ok(agents(vec![row("ruth", None)], Some(Ok(true))));
|
o.agents = Ok(agents(vec![row("ruth", None)], Some(Ok(true))));
|
||||||
assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("no declared")));
|
assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("no declared")));
|
||||||
|
|
@ -836,21 +963,105 @@ mod tests {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!complete(&classify(&o)));
|
assert!(!complete(&classify(&o)));
|
||||||
let text = render(&classify(&o));
|
let text = render(&classify(&o), false);
|
||||||
|
assert!(text.contains("❓ 1 secret store initialised"));
|
||||||
assert!(text.contains("unknown (bao is not on PATH)"));
|
assert!(text.contains("unknown (bao is not on PATH)"));
|
||||||
|
assert!(text.starts_with("🏗️ setup: 0/8 steps done ░░░░░░░░░░ ❓ 8 unknown\n"));
|
||||||
assert!(text.contains("next: step 1 could not be checked"));
|
assert!(text.contains("next: step 1 could not be checked"));
|
||||||
|
assert!(!text.contains("👉"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending() -> Observed {
|
||||||
|
let mut o = finished();
|
||||||
|
o.admin_user = Ok(false);
|
||||||
|
o.agents = Ok(agents(
|
||||||
|
vec![row("ruth", Some("paused")), row("iris", Some("paused"))],
|
||||||
|
Some(Ok(true)),
|
||||||
|
));
|
||||||
|
o
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_report_names_the_next_step() {
|
fn the_report_marks_the_first_open_step_and_hints_at_it() {
|
||||||
let mut o = finished();
|
let text = render(&classify(&pending()), false);
|
||||||
o.admin_user = Ok(false);
|
assert_eq!(
|
||||||
let text = render(&classify(&o));
|
text,
|
||||||
assert!(text.contains(" 6 SSO account in the admins group: not done"));
|
"\
|
||||||
assert!(text.contains("next: step 6, setup.md § 2"));
|
🏗️ setup: 6/8 steps done ███████░░░
|
||||||
assert!(text.contains("unknown (not observable:"));
|
|
||||||
// Nothing left to do, so no pointer.
|
✅ 1 secret store initialised
|
||||||
assert!(!render(&classify(&finished())).contains("next:"));
|
✅ 2 secret store unsealed
|
||||||
|
✅ 3 granter bootstrapped the granting units log in through it
|
||||||
|
✅ 4 granting units active 2 units
|
||||||
|
✅ 5 store unsealed after the granting units
|
||||||
|
👉 6 SSO account in the admins group no enabled user in the \"admins\" group; the placeholder is disabled
|
||||||
|
↳ setup.md § 2: `swarmctl user add <name> --email <address> --group admins`
|
||||||
|
❓ 7 first swarm UI and forge login unknown (not observable: a browser login leaves nothing to read)
|
||||||
|
❓ 8 forge site admin unknown (not observable: the controller has no read route for it; `forge make-admin` is safe to re-run)
|
||||||
|
⬜ 9 ruth created and up ruth is declared paused
|
||||||
|
✅ 10 another agent [optional] 1 besides ruth
|
||||||
|
|
||||||
|
next: step 6, setup.md § 2: `swarmctl user add <name> --email <address> --group admins`
|
||||||
|
"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_complete_ceremony_ends_on_the_celebration_line() {
|
||||||
|
let text = render(&classify(&finished()), false);
|
||||||
|
assert!(text.starts_with("🏗️ setup: 8/8 steps done ██████████\n"));
|
||||||
|
assert!(text.ends_with("🎉 setup complete\n"));
|
||||||
|
assert!(!text.contains("next:") && !text.contains("👉"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_header_counts_unknown_steps_apart_from_done_ones() {
|
||||||
|
let mut o = pending();
|
||||||
|
o.agents = Err("no socket".to_owned());
|
||||||
|
let text = render(&classify(&o), false);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("🏗️ setup: 5/8 steps done ██████░░░░ ❓ 2 unknown\n"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colour_only_wraps_the_text_and_the_emoji_stay_either_way() {
|
||||||
|
let plain = render(&classify(&pending()), false);
|
||||||
|
let colour = render(&classify(&pending()), true);
|
||||||
|
assert!(!plain.contains('\x1b'));
|
||||||
|
assert!(colour.contains("\x1b[32m") && colour.contains("\x1b[33m"));
|
||||||
|
// Stripping the escapes gives the plain report back.
|
||||||
|
let mut stripped = String::new();
|
||||||
|
let mut chars = colour.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\x1b' {
|
||||||
|
for e in chars.by_ref() {
|
||||||
|
if e == 'm' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stripped.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(stripped, plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colour_needs_a_terminal_and_no_no_color() {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
assert!(color_wanted(true, None));
|
||||||
|
assert!(color_wanted(true, Some(OsStr::new(""))));
|
||||||
|
assert!(!color_wanted(true, Some(OsStr::new("1"))));
|
||||||
|
assert!(!color_wanted(false, None));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn the_bar_is_full_only_when_everything_is_done() {
|
||||||
|
assert_eq!(bar(0, 8), "░░░░░░░░░░");
|
||||||
|
assert_eq!(bar(7, 8), "████████░░");
|
||||||
|
assert_eq!(bar(8, 8), "██████████");
|
||||||
|
assert_eq!(bar(0, 0), "░░░░░░░░░░");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue