use similar::{ChangeTag, TextDiff};
use smol_str::SmolStr;
use std::{
fmt,
io::Write,
path::{Path, PathBuf},
sync::Arc,
};
use schemars::JsonSchema;
use serde::Serialize;
use crate::{
ansi::Styler,
config_files::{ConfigFileDirs, ConfigFilePath},
git::GitFileStatus,
};
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PkgEntry {
Pkg {
name: SmolStr,
key: SmolStr,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
state: PkgEntryState,
#[serde(skip_serializing_if = "Option::is_none")]
matched_detect: Option<String>,
install_hints: PkgInstallHints,
},
AggregateInstall {
pkg_manager: String,
command: String,
packages: Vec<String>,
},
NoPackageManagerDetected {
supported: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PkgEntryState {
Disabled,
Installed,
Missing,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, JsonSchema)]
pub struct PkgInstallHints {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub brew: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DoctorCheck {
Check {
section: DoctorSection,
label: SmolStr,
severity: DoctorSeverity,
value: String,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
detail: Vec<String>,
},
SectionHeader {
section: DoctorSection,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DoctorSection {
System,
Repo,
Config,
PkgManager,
User,
Shell,
Packages,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DoctorSeverity {
Ok,
Info,
Warn,
Bad,
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
pub struct InitSummary {
pub clone_path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
pub pkg_count: usize,
}
#[derive(Debug, PartialEq, Clone, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum SymlinkStatus {
Ok,
WrongLink(PathBuf),
New,
IsFile,
IsDir,
RealPathIsMissing,
DstDirIsMissing,
}
#[derive(Debug, PartialEq, Clone, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FileStatus {
Ok,
Modified,
New,
}
#[derive(Debug, PartialEq, Clone, Serialize, JsonSchema)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum PkgStatus {
Ok,
Missing {
install_command: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
pub struct ResolvedConfigFilePath {
pub path: ConfigFilePath,
pub full: Arc<Path>,
}
impl ResolvedConfigFilePath {
pub fn resolve(path: ConfigFilePath, dirs: &ConfigFileDirs) -> Self {
let full = Arc::from(path.resolved(dirs));
Self { path, full }
}
pub fn parent(&self) -> Option<Self> {
Some(Self {
path: self.path.parent()?,
full: self.full.parent().map(Arc::from)?,
})
}
}
impl fmt::Display for ResolvedConfigFilePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.path.human_path(), f)
}
}
#[derive(Debug, PartialEq, Clone, Serialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Status {
Generated {
want_content: Arc<str>,
cur_content: Option<String>,
path: ResolvedConfigFilePath,
status: FileStatus,
},
Symlink {
real: ResolvedConfigFilePath,
symlink: ResolvedConfigFilePath,
status: SymlinkStatus,
},
Git {
repo: ResolvedConfigFilePath,
status: GitFileStatus,
},
GitRepoClean {
repo: ResolvedConfigFilePath,
},
Pkg {
pkg: SmolStr,
status: PkgStatus,
},
}
#[derive(Debug, PartialEq, Serialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AppliedAction {
UpdatedFile(ResolvedConfigFilePath),
CreatedFile(ResolvedConfigFilePath),
CreatedSymlink {
real: ResolvedConfigFilePath,
symlink: ResolvedConfigFilePath,
},
CreatedDir(ResolvedConfigFilePath),
}
#[derive(Debug, thiserror::Error)]
pub enum OutputError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
pub trait Output {
fn push_status(&mut self, status: Status) -> Result<(), OutputError>;
fn push_applied_action(&mut self, action: AppliedAction) -> Result<(), OutputError>;
fn push_pkg_entry(&mut self, _entry: PkgEntry) -> Result<(), OutputError> {
Ok(())
}
fn push_doctor_check(&mut self, _check: DoctorCheck) -> Result<(), OutputError> {
Ok(())
}
fn push_init_summary(&mut self, _summary: InitSummary) -> Result<(), OutputError> {
Ok(())
}
fn finalize(&mut self) -> Result<(), OutputError> {
Ok(())
}
}
pub struct JsonOutput<'w> {
out: &'w mut dyn Write,
}
impl<'w> JsonOutput<'w> {
pub fn new(out: &'w mut dyn Write) -> Self {
Self { out }
}
}
#[derive(Serialize, JsonSchema)]
#[serde(tag = "event", rename_all = "snake_case")]
pub(crate) enum Event {
Status(Status),
AppliedAction(AppliedAction),
PkgEntry(PkgEntry),
DoctorCheck(DoctorCheck),
InitSummary(InitSummary),
}
impl Output for JsonOutput<'_> {
fn push_status(&mut self, status: Status) -> Result<(), OutputError> {
serde_json::to_writer(&mut *self.out, &Event::Status(status))?;
writeln!(self.out)?;
Ok(())
}
fn push_applied_action(&mut self, action: AppliedAction) -> Result<(), OutputError> {
serde_json::to_writer(&mut *self.out, &Event::AppliedAction(action))?;
writeln!(self.out)?;
Ok(())
}
fn push_pkg_entry(&mut self, entry: PkgEntry) -> Result<(), OutputError> {
serde_json::to_writer(&mut *self.out, &Event::PkgEntry(entry))?;
writeln!(self.out)?;
Ok(())
}
fn push_doctor_check(&mut self, check: DoctorCheck) -> Result<(), OutputError> {
if matches!(check, DoctorCheck::SectionHeader { .. }) {
return Ok(());
}
serde_json::to_writer(&mut *self.out, &Event::DoctorCheck(check))?;
writeln!(self.out)?;
Ok(())
}
fn push_init_summary(&mut self, summary: InitSummary) -> Result<(), OutputError> {
serde_json::to_writer(&mut *self.out, &Event::InitSummary(summary))?;
writeln!(self.out)?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Style {
Default,
Dim,
ExtraDim,
Bold,
Red,
Green,
Yellow,
Cyan,
Magenta,
BoldYellow,
}
impl Style {
fn open(self, s: &Styler) -> &'static str {
match self {
Style::Default => "",
Style::Dim => s.dim(),
Style::ExtraDim => s.extra_dim(),
Style::Bold => s.bold(),
Style::Red => s.red(),
Style::Green => s.green(),
Style::Yellow => s.yellow(),
Style::Cyan => s.cyan(),
Style::Magenta => s.magenta(),
Style::BoldYellow => s.bold_yellow(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Segment {
text: String,
style: Style,
}
impl Segment {
fn new(style: Style, text: impl Into<String>) -> Self {
Self {
text: text.into(),
style,
}
}
}
#[derive(Debug)]
struct Line {
marker: char,
marker_style: Style,
path: Vec<Segment>,
description: Vec<Segment>,
}
pub struct TerminalRenderer<'w> {
out: &'w mut dyn Write,
styler: Styler,
show_diffs: bool,
show_clean: bool,
pending: Pending,
lines: Vec<Line>,
diffs: Vec<PendingDiff>,
pkg_rows: Vec<PkgRow>,
pkg_aggregate: Option<PkgAggregate>,
last_doctor_section: Option<DoctorSection>,
finalized: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pending {
None,
StatusBlock,
PkgBlock,
}
#[derive(Debug)]
struct PendingDiff {
path: ResolvedConfigFilePath,
cur_content: Option<String>,
want_content: Arc<str>,
}
#[derive(Debug)]
struct PkgRow {
name: SmolStr,
description: Option<String>,
state: PkgEntryState,
matched_detect: Option<String>,
install_hints: PkgInstallHints,
}
#[derive(Debug)]
struct PkgAggregate {
pkg_manager: String,
command: String,
}
impl<'w> TerminalRenderer<'w> {
pub fn new(out: &'w mut dyn Write, color: bool, show_diffs: bool, show_clean: bool) -> Self {
Self {
out,
styler: Styler::new(color),
show_diffs,
show_clean,
pending: Pending::None,
lines: Vec::new(),
diffs: Vec::new(),
pkg_rows: Vec::new(),
pkg_aggregate: None,
last_doctor_section: None,
finalized: false,
}
}
fn write_line(&mut self, line: &Line, path_width: usize) -> Result<(), OutputError> {
let s = &self.styler;
let reset = s.reset();
let marker_open = line.marker_style.open(s);
let path_chars: usize = line.path.iter().map(|seg| seg.text.chars().count()).sum();
let pad = path_width.saturating_sub(path_chars);
write!(
self.out,
"{marker_open}{marker}{reset} ",
marker = line.marker,
)?;
for seg in &line.path {
let open = seg.style.open(s);
write!(self.out, "{open}{text}{reset}", text = seg.text)?;
}
write!(self.out, "{spaces}", spaces = " ".repeat(pad))?;
if !line.description.is_empty() {
write!(self.out, " ")?;
for seg in &line.description {
let open = seg.style.open(s);
write!(self.out, "{open}{text}{reset}", text = seg.text)?;
}
}
writeln!(self.out)?;
Ok(())
}
fn write_diff(&mut self, diff: &PendingDiff) -> Result<(), OutputError> {
let old = diff.cur_content.as_deref().unwrap_or("");
if diff.cur_content.is_some() {
writeln!(self.out, "--- {} (current)", diff.path)?;
} else {
writeln!(self.out, "--- /dev/null")?;
}
writeln!(self.out, "+++ {} (generated)", diff.path)?;
let text_diff = TextDiff::from_lines(old, &*diff.want_content);
let s = &self.styler;
let reset = s.reset();
for change in text_diff.iter_all_changes() {
let (prefix, open) = match change.tag() {
ChangeTag::Delete => ("-", s.red()),
ChangeTag::Insert => ("+", s.green()),
ChangeTag::Equal => (" ", s.dim()),
};
write!(self.out, "{open}{prefix}{change}{reset}")?;
}
Ok(())
}
}
fn ok_line(path: Vec<Segment>, description: &'static str) -> Line {
Line {
marker: '✓',
marker_style: Style::Green,
path,
description: vec![Segment::new(Style::Green, description)],
}
}
fn path_segments(path: &ResolvedConfigFilePath) -> Vec<Segment> {
if let ConfigFilePath::Zenops(rel) = &path.path
&& !rel.as_str().is_empty()
{
return vec![
Segment::new(Style::ExtraDim, "~/.config/zenops"),
Segment::new(Style::Default, format!("/{rel}")),
];
}
vec![Segment::new(Style::Dim, path.to_string())]
}
fn symlink_path_segments(
symlink: &ResolvedConfigFilePath,
real: &ResolvedConfigFilePath,
) -> Vec<Segment> {
let mut segs = vec![
Segment::new(Style::Dim, symlink.to_string()),
Segment::new(Style::Bold, " → "),
];
segs.extend(path_segments(real));
segs
}
fn git_path_segments(repo: &ResolvedConfigFilePath, sub: String) -> Vec<Segment> {
vec![
Segment::new(Style::ExtraDim, repo.to_string()),
Segment::new(Style::Default, format!("/{sub}")),
]
}
fn raw_dim_path(s: impl Into<String>) -> Vec<Segment> {
vec![Segment::new(Style::Dim, s)]
}
fn status_to_line(status: &Status, show_clean: bool) -> Option<Line> {
match status {
Status::Generated {
path,
status: FileStatus::Ok,
..
} => show_clean.then(|| ok_line(path_segments(path), "ok")),
Status::Generated {
path,
status: FileStatus::Modified,
..
} => Some(Line {
marker: '~',
marker_style: Style::Yellow,
path: path_segments(path),
description: vec![Segment::new(Style::Yellow, "modified")],
}),
Status::Generated {
path,
status: FileStatus::New,
..
} => Some(Line {
marker: '+',
marker_style: Style::Yellow,
path: path_segments(path),
description: vec![Segment::new(Style::Yellow, "missing")],
}),
Status::Symlink {
real,
symlink,
status: SymlinkStatus::Ok,
} => show_clean.then(|| ok_line(symlink_path_segments(symlink, real), "ok")),
Status::Symlink {
real,
symlink,
status: SymlinkStatus::WrongLink(actual),
} => Some(Line {
marker: '✗',
marker_style: Style::Red,
path: symlink_path_segments(symlink, real),
description: vec![
Segment::new(Style::Red, "wrong target"),
Segment::new(Style::Dim, format!(" {}", actual.display())),
],
}),
Status::Symlink {
real,
symlink,
status: SymlinkStatus::New,
} => Some(Line {
marker: '+',
marker_style: Style::Yellow,
path: symlink_path_segments(symlink, real),
description: vec![Segment::new(Style::Yellow, "missing")],
}),
Status::Symlink {
symlink,
status: SymlinkStatus::IsFile,
..
} => Some(Line {
marker: '✗',
marker_style: Style::Red,
path: path_segments(symlink),
description: vec![
Segment::new(Style::Red, "is a file"),
Segment::new(Style::Dim, ", expected symlink"),
],
}),
Status::Symlink {
symlink,
status: SymlinkStatus::IsDir,
..
} => Some(Line {
marker: '✗',
marker_style: Style::Red,
path: path_segments(symlink),
description: vec![
Segment::new(Style::Red, "is a dir"),
Segment::new(Style::Dim, ", expected symlink"),
],
}),
Status::Symlink {
real,
status: SymlinkStatus::RealPathIsMissing,
..
} => Some(Line {
marker: '✗',
marker_style: Style::Red,
path: path_segments(real),
description: vec![Segment::new(Style::Red, "symlink source missing")],
}),
Status::Symlink {
symlink,
status: SymlinkStatus::DstDirIsMissing,
..
} => Some(Line {
marker: '✗',
marker_style: Style::Red,
path: path_segments(symlink),
description: vec![Segment::new(Style::Red, "parent directory missing")],
}),
Status::Git { repo, status } => match status {
GitFileStatus::Modified(p) => Some(Line {
marker: 'M',
marker_style: Style::Yellow,
path: git_path_segments(repo, p.to_string()),
description: vec![Segment::new(Style::Yellow, "modified")],
}),
GitFileStatus::Added(p) => Some(Line {
marker: 'A',
marker_style: Style::Yellow,
path: git_path_segments(repo, p.to_string()),
description: vec![Segment::new(Style::Yellow, "added")],
}),
GitFileStatus::Deleted(p) => Some(Line {
marker: 'D',
marker_style: Style::Red,
path: git_path_segments(repo, p.to_string()),
description: vec![Segment::new(Style::Red, "deleted")],
}),
GitFileStatus::Untracked(p) => Some(Line {
marker: '?',
marker_style: Style::Cyan,
path: git_path_segments(repo, p.to_string()),
description: vec![Segment::new(Style::Cyan, "untracked")],
}),
GitFileStatus::Other { code, path } => Some(Line {
marker: '!',
marker_style: Style::Magenta,
path: git_path_segments(repo, path.to_string()),
description: vec![
Segment::new(Style::Dim, "status "),
Segment::new(Style::Magenta, code.to_string()),
],
}),
},
Status::Pkg {
pkg,
status: PkgStatus::Missing {
install_command: Some(cmd),
},
} => Some(Line {
marker: '✗',
marker_style: Style::Red,
path: raw_dim_path(pkg.to_string()),
description: vec![
Segment::new(Style::Red, "missing"),
Segment::new(Style::Dim, " — install: "),
Segment::new(Style::BoldYellow, cmd.clone()),
],
}),
Status::Pkg {
pkg,
status: PkgStatus::Missing {
install_command: None,
},
} => Some(Line {
marker: '✗',
marker_style: Style::Red,
path: raw_dim_path(pkg.to_string()),
description: vec![Segment::new(Style::Red, "missing")],
}),
Status::Pkg {
pkg,
status: PkgStatus::Ok,
} => show_clean.then(|| ok_line(raw_dim_path(pkg.to_string()), "ok")),
Status::GitRepoClean { repo } => show_clean.then(|| ok_line(path_segments(repo), "clean")),
}
}
fn action_to_line(action: &AppliedAction) -> Line {
match action {
AppliedAction::UpdatedFile(path) => Line {
marker: '✓',
marker_style: Style::Green,
path: path_segments(path),
description: vec![Segment::new(Style::Green, "updated")],
},
AppliedAction::CreatedFile(path) => Line {
marker: '✓',
marker_style: Style::Green,
path: path_segments(path),
description: vec![Segment::new(Style::Green, "created")],
},
AppliedAction::CreatedSymlink { real, symlink } => Line {
marker: '✓',
marker_style: Style::Green,
path: symlink_path_segments(symlink, real),
description: vec![Segment::new(Style::Green, "linked")],
},
AppliedAction::CreatedDir(path) => Line {
marker: '✓',
marker_style: Style::Green,
path: path_segments(path),
description: vec![Segment::new(Style::Green, "mkdir")],
},
}
}
impl TerminalRenderer<'_> {
fn enter(&mut self, kind: Pending) -> Result<(), OutputError> {
if self.pending != kind {
self.flush_pending()?;
self.pending = kind;
}
Ok(())
}
fn flush_pending(&mut self) -> Result<(), OutputError> {
match self.pending {
Pending::None => {}
Pending::StatusBlock => self.flush_status_block()?,
Pending::PkgBlock => self.flush_pkg_block()?,
}
self.pending = Pending::None;
Ok(())
}
fn flush_status_block(&mut self) -> Result<(), OutputError> {
if self.lines.is_empty() && self.diffs.is_empty() {
return Ok(());
}
let path_width = self
.lines
.iter()
.map(|l| l.path.iter().map(|seg| seg.text.chars().count()).sum())
.max()
.unwrap_or(0);
let lines = std::mem::take(&mut self.lines);
for line in &lines {
self.write_line(line, path_width)?;
}
let diffs = std::mem::take(&mut self.diffs);
for diff in &diffs {
writeln!(self.out)?;
self.write_diff(diff)?;
}
Ok(())
}
fn flush_pkg_block(&mut self) -> Result<(), OutputError> {
let rows = std::mem::take(&mut self.pkg_rows);
let aggregate = self.pkg_aggregate.take();
if rows.is_empty() && aggregate.is_none() {
return Ok(());
}
let name_width = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
let s = &self.styler;
let reset = s.reset();
let indent = " ".repeat(2 + name_width + 2);
for row in &rows {
let (status_color, marker) = match row.state {
PkgEntryState::Disabled => (s.dim(), "-"),
PkgEntryState::Installed => (s.green(), "\u{2713}"),
PkgEntryState::Missing => (s.red(), "\u{2717}"),
};
write!(
self.out,
"{status_color}{marker}{reset} {bold}{name:<name_width$}{reset}",
bold = s.bold(),
name = row.name,
)?;
if let Some(desc) = &row.description {
write!(self.out, " {dim}{desc}{reset}", dim = s.dim())?;
}
writeln!(self.out)?;
if let Some(detect) = &row.matched_detect {
writeln!(
self.out,
"{indent}{dim}detect: {detect}{reset}",
dim = s.dim(),
)?;
}
for hint_line in pkg_hint_lines(row) {
writeln!(
self.out,
"{indent}{hint}{hint_line}{reset}",
hint = s.bold_yellow(),
)?;
}
}
if let Some(agg) = aggregate {
writeln!(self.out)?;
writeln!(
self.out,
"{hint}To install all missing via {mgr}: {cmd}{reset}",
hint = s.bold_yellow(),
mgr = agg.pkg_manager,
cmd = agg.command,
)?;
}
Ok(())
}
fn render_doctor_section_header(&mut self, section: DoctorSection) -> Result<(), OutputError> {
if self.last_doctor_section == Some(section) {
return Ok(());
}
if self.last_doctor_section.is_some() {
writeln!(self.out)?;
}
let title = doctor_section_title(section);
let s = &self.styler;
writeln!(self.out, "{}{}{}", s.bold(), title, s.reset())?;
self.last_doctor_section = Some(section);
Ok(())
}
fn render_doctor_check(&mut self, check: &DoctorCheck) -> Result<(), OutputError> {
match check {
DoctorCheck::SectionHeader { section } => {
self.render_doctor_section_header(*section)?;
}
DoctorCheck::Check {
section,
label,
severity,
value,
hint,
detail,
} => {
self.render_doctor_section_header(*section)?;
let s = &self.styler;
let reset = s.reset();
let color_open = match severity {
DoctorSeverity::Ok => s.green(),
DoctorSeverity::Info => "",
DoctorSeverity::Warn => s.yellow(),
DoctorSeverity::Bad => s.red(),
};
if let Some(hint) = hint {
writeln!(
self.out,
" {label:<14} {color_open}{value}{reset} {dim}{hint}{reset}",
dim = s.dim(),
label = label.as_str(),
)?;
} else {
writeln!(
self.out,
" {label:<14} {color_open}{value}{reset}",
label = label.as_str(),
)?;
}
for line in detail {
writeln!(self.out, " {line}")?;
}
}
}
Ok(())
}
fn render_init_summary(&mut self, summary: &InitSummary) -> Result<(), OutputError> {
writeln!(self.out, "Cloned into {}", summary.clone_path.display())?;
if let Some(remote) = &summary.remote {
writeln!(self.out, " remote: {remote}")?;
}
match &summary.shell {
Some(shell) => writeln!(self.out, " shell: {shell}")?,
None => writeln!(self.out, " shell: (none configured)")?,
}
writeln!(self.out, " pkgs: {}", summary.pkg_count)?;
writeln!(
self.out,
"Next: run `zenops apply` to realize this config on your system."
)?;
Ok(())
}
}
impl Output for TerminalRenderer<'_> {
fn push_status(&mut self, status: Status) -> Result<(), OutputError> {
self.enter(Pending::StatusBlock)?;
if self.show_diffs
&& let Status::Generated {
want_content,
cur_content,
path,
status: FileStatus::Modified | FileStatus::New,
} = &status
{
self.diffs.push(PendingDiff {
path: path.clone(),
cur_content: cur_content.clone(),
want_content: Arc::clone(want_content),
});
}
if let Some(line) = status_to_line(&status, self.show_clean) {
self.lines.push(line);
}
Ok(())
}
fn push_applied_action(&mut self, action: AppliedAction) -> Result<(), OutputError> {
self.enter(Pending::StatusBlock)?;
self.lines.push(action_to_line(&action));
Ok(())
}
fn push_pkg_entry(&mut self, entry: PkgEntry) -> Result<(), OutputError> {
match entry {
PkgEntry::NoPackageManagerDetected { supported } => {
self.enter(Pending::None)?;
writeln!(
self.out,
"note: no known package manager detected on PATH; install \
guidance will be hidden. Supported managers: {}.",
supported.join(", "),
)?;
}
PkgEntry::Pkg {
name,
key: _,
description,
state,
matched_detect,
install_hints,
} => {
self.enter(Pending::PkgBlock)?;
self.pkg_rows.push(PkgRow {
name,
description,
state,
matched_detect,
install_hints,
});
}
PkgEntry::AggregateInstall {
pkg_manager,
command,
packages: _,
} => {
self.enter(Pending::PkgBlock)?;
self.pkg_aggregate = Some(PkgAggregate {
pkg_manager,
command,
});
}
}
Ok(())
}
fn push_doctor_check(&mut self, check: DoctorCheck) -> Result<(), OutputError> {
self.enter(Pending::None)?;
self.render_doctor_check(&check)
}
fn push_init_summary(&mut self, summary: InitSummary) -> Result<(), OutputError> {
self.enter(Pending::None)?;
self.render_init_summary(&summary)
}
fn finalize(&mut self) -> Result<(), OutputError> {
if self.finalized {
return Ok(());
}
self.finalized = true;
self.flush_pending()
}
}
fn doctor_section_title(section: DoctorSection) -> &'static str {
match section {
DoctorSection::System => "System",
DoctorSection::Repo => "Config repo (~/.config/zenops)",
DoctorSection::Config => "Config (~/.config/zenops/config.toml)",
DoctorSection::PkgManager => "Package manager",
DoctorSection::User => "User",
DoctorSection::Shell => "Shell",
DoctorSection::Packages => "Packages",
}
}
fn pkg_hint_lines(row: &PkgRow) -> Vec<String> {
if !matches!(row.state, PkgEntryState::Missing) {
return Vec::new();
}
let mut lines = Vec::new();
if !row.install_hints.brew.is_empty() {
lines.push(format!("brew: {}", row.install_hints.brew.join(" ")));
}
lines
}
#[cfg(test)]
mod tests {
use similar_asserts::assert_eq;
use zenops_safe_relative_path::{SafeRelativePath, SafeRelativePathBuf, srpath};
use super::*;
use crate::config_files::ConfigFilePath;
fn home_path(rel: &str) -> ResolvedConfigFilePath {
let srp = SafeRelativePath::from_relative_path(rel).unwrap();
ResolvedConfigFilePath {
path: ConfigFilePath::in_home(srp),
full: Arc::from(Path::new("/home/test").join(rel)),
}
}
fn render_status(status: Status, color: bool, show_diffs: bool) -> String {
render_status_full(status, color, show_diffs, false)
}
fn render_status_full(
status: Status,
color: bool,
show_diffs: bool,
show_clean: bool,
) -> String {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, color, show_diffs, show_clean);
r.push_status(status).unwrap();
r.finalize().unwrap();
}
String::from_utf8(buf).unwrap()
}
fn render_action(action: AppliedAction) -> String {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, false, false, false);
r.push_applied_action(action).unwrap();
r.finalize().unwrap();
}
String::from_utf8(buf).unwrap()
}
fn generated(cur: Option<&str>, want: &str, rel: &str, status: FileStatus) -> Status {
Status::Generated {
want_content: Arc::from(want),
cur_content: cur.map(String::from),
path: home_path(rel),
status,
}
}
#[test]
fn generated_ok_emits_nothing() {
let s = generated(Some("x\n"), "x\n", "a.toml", FileStatus::Ok);
assert_eq!(render_status(s, false, false), "");
}
#[test]
fn generated_ok_with_show_clean_renders_checkmark_line() {
let s = generated(Some("x\n"), "x\n", "a.toml", FileStatus::Ok);
assert_eq!(
render_status_full(s, false, false, true),
"✓ ~/a.toml ok\n"
);
}
#[test]
fn generated_modified_renders_tilde_marker_and_modified_word() {
let s = generated(Some("a\n"), "b\n", "a.toml", FileStatus::Modified);
assert_eq!(render_status(s, false, false), "~ ~/a.toml modified\n",);
}
#[test]
fn generated_new_renders_plus_marker_and_missing_word() {
let s = generated(None, "x\n", "a.toml", FileStatus::New);
assert_eq!(render_status(s, false, false), "+ ~/a.toml missing\n");
}
#[test]
fn generated_modified_with_diff_renders_summary_then_blank_then_diff() {
let s = generated(Some("a\n"), "b\n", "a.toml", FileStatus::Modified);
let got = render_status(s, false, true);
assert!(got.starts_with("~ ~/a.toml modified\n"), "{got:?}");
assert!(
got.contains("\n--- ~/a.toml (current)\n+++ ~/a.toml (generated)\n"),
"{got:?}",
);
assert!(got.contains("-a\n"), "{got:?}");
assert!(got.contains("+b\n"), "{got:?}");
}
#[test]
fn generated_new_with_diff_labels_dev_null() {
let s = generated(None, "x\n", "a.toml", FileStatus::New);
let got = render_status(s, false, true);
assert!(got.starts_with("+ ~/a.toml missing\n"), "{got:?}");
assert!(
got.contains("--- /dev/null\n+++ ~/a.toml (generated)\n"),
"{got:?}",
);
assert!(got.contains("+x\n"), "{got:?}");
}
#[test]
fn generated_with_diff_color_off_contains_no_ansi_escapes() {
let s = generated(Some("a\n"), "b\n", "a.toml", FileStatus::Modified);
let got = render_status(s, false, true);
assert!(!got.contains('\x1b'), "unexpected ANSI escape: {got:?}");
}
#[test]
fn generated_with_diff_color_on_emits_ansi_escapes() {
let s = generated(Some("a\n"), "b\n", "a.toml", FileStatus::Modified);
let got = render_status(s, true, true);
assert!(got.contains("\x1b[31m-a\n\x1b[0m"), "{got:?}");
assert!(got.contains("\x1b[32m+b\n\x1b[0m"), "{got:?}");
}
#[test]
fn generated_empty_current_still_labels_as_current() {
let s = generated(Some(""), "hi\n", "empty.toml", FileStatus::Modified);
let got = render_status(s, false, true);
assert!(got.contains("--- ~/empty.toml (current)\n"), "{got:?}");
}
fn symlink(real: &str, sym: &str, status: SymlinkStatus) -> Status {
Status::Symlink {
real: home_path(real),
symlink: home_path(sym),
status,
}
}
#[test]
fn symlink_ok_emits_nothing() {
let s = symlink("src", "dst", SymlinkStatus::Ok);
assert_eq!(render_status(s, false, false), "");
}
#[test]
fn symlink_ok_with_show_clean_renders_checkmark_line() {
let s = symlink("src", "dst", SymlinkStatus::Ok);
assert_eq!(
render_status_full(s, false, false, true),
"✓ ~/dst → ~/src ok\n",
);
}
#[test]
fn symlink_wrong_link_renders_arrow_and_actual_target() {
let s = symlink(
"src",
"dst",
SymlinkStatus::WrongLink(PathBuf::from("/other")),
);
assert_eq!(
render_status(s, false, false),
"✗ ~/dst → ~/src wrong target /other\n",
);
}
#[test]
fn symlink_new_renders_plus_and_arrow() {
assert_eq!(
render_status(symlink("s", "d", SymlinkStatus::New), false, false),
"+ ~/d → ~/s missing\n",
);
}
#[test]
fn symlink_is_file_renders_cross_and_description() {
assert_eq!(
render_status(symlink("s", "d", SymlinkStatus::IsFile), false, false),
"✗ ~/d is a file, expected symlink\n",
);
}
#[test]
fn symlink_is_dir_renders_cross_and_description() {
assert_eq!(
render_status(symlink("s", "d", SymlinkStatus::IsDir), false, false),
"✗ ~/d is a dir, expected symlink\n",
);
}
#[test]
fn symlink_real_missing_reports_source_path() {
assert_eq!(
render_status(
symlink("s", "d", SymlinkStatus::RealPathIsMissing),
false,
false,
),
"✗ ~/s symlink source missing\n",
);
}
#[test]
fn symlink_dst_dir_missing_reports_symlink_path() {
assert_eq!(
render_status(
symlink("s", "d", SymlinkStatus::DstDirIsMissing),
false,
false,
),
"✗ ~/d parent directory missing\n",
);
}
fn zenops_path(rel: &str) -> ResolvedConfigFilePath {
let srp = SafeRelativePath::from_relative_path(rel).unwrap();
ResolvedConfigFilePath {
path: ConfigFilePath::Zenops(Arc::from(srp)),
full: Arc::from(Path::new("/home/test/.config/zenops").join(rel)),
}
}
fn relpath(s: &str) -> SafeRelativePathBuf {
srpath!("").safe_join(SafeRelativePath::from_relative_path(s).unwrap())
}
#[test]
fn git_variants_render_expected_lines() {
let repo = zenops_path("");
let cases: Vec<(GitFileStatus, &str)> = vec![
(
GitFileStatus::Modified(relpath("a.toml")),
"M ~/.config/zenops/a.toml modified\n",
),
(
GitFileStatus::Added(relpath("b.toml")),
"A ~/.config/zenops/b.toml added\n",
),
(
GitFileStatus::Deleted(relpath("c.toml")),
"D ~/.config/zenops/c.toml deleted\n",
),
(
GitFileStatus::Untracked(relpath("d.toml")),
"? ~/.config/zenops/d.toml untracked\n",
),
(
GitFileStatus::Other {
code: SmolStr::new_static("UU"),
path: relpath("e.toml"),
},
"! ~/.config/zenops/e.toml status UU\n",
),
];
for (status, want) in cases {
let s = Status::Git {
repo: repo.clone(),
status,
};
assert_eq!(render_status(s, false, false), want);
}
}
fn pkg_missing(pkg: &'static str, install_command: Option<&str>) -> Status {
Status::Pkg {
pkg: SmolStr::new_static(pkg),
status: PkgStatus::Missing {
install_command: install_command.map(String::from),
},
}
}
fn pkg_ok(pkg: &'static str) -> Status {
Status::Pkg {
pkg: SmolStr::new_static(pkg),
status: PkgStatus::Ok,
}
}
#[test]
fn pkg_missing_with_install_command_includes_hint() {
assert_eq!(
render_status(
pkg_missing("python", Some("brew install python")),
false,
false
),
"✗ python missing — install: brew install python\n",
);
}
#[test]
fn pkg_missing_without_install_command_is_terse() {
assert_eq!(
render_status(pkg_missing("python", None), false, false),
"✗ python missing\n",
);
}
#[test]
fn pkg_ok_without_show_clean_emits_nothing() {
assert_eq!(render_status(pkg_ok("python"), false, false), "");
}
#[test]
fn pkg_ok_with_show_clean_renders_checkmark_line() {
assert_eq!(
render_status_full(pkg_ok("python"), false, false, true),
"✓ python ok\n",
);
}
#[test]
fn git_repo_clean_without_show_clean_emits_nothing() {
let s = Status::GitRepoClean {
repo: zenops_path(""),
};
assert_eq!(render_status(s, false, false), "");
}
#[test]
fn git_repo_clean_with_show_clean_renders_checkmark_line() {
let s = Status::GitRepoClean {
repo: zenops_path(""),
};
assert_eq!(
render_status_full(s, false, false, true),
"✓ ~/.config/zenops clean\n",
);
}
#[test]
fn applied_actions_render_expected_lines() {
assert_eq!(
render_action(AppliedAction::UpdatedFile(home_path("a.toml"))),
"✓ ~/a.toml updated\n",
);
assert_eq!(
render_action(AppliedAction::CreatedFile(home_path("a.toml"))),
"✓ ~/a.toml created\n",
);
assert_eq!(
render_action(AppliedAction::CreatedSymlink {
real: home_path("src"),
symlink: home_path("dst"),
}),
"✓ ~/dst → ~/src linked\n",
);
assert_eq!(
render_action(AppliedAction::CreatedDir(home_path("subdir"))),
"✓ ~/subdir mkdir\n",
);
}
#[test]
fn multiple_lines_pad_path_column_to_widest() {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, false, false, false);
r.push_status(pkg_missing("py", None)).unwrap();
r.push_status(generated(
Some("a\n"),
"b\n",
"long/nested/path/file.toml",
FileStatus::Modified,
))
.unwrap();
r.finalize().unwrap();
}
let got = String::from_utf8(buf).unwrap();
let wide = "~/long/nested/path/file.toml".chars().count();
let short = "py".chars().count();
let pad = wide - short;
let expected = format!(
"✗ py{spaces} missing\n~ ~/long/nested/path/file.toml modified\n",
spaces = " ".repeat(pad),
);
assert_eq!(got, expected);
}
#[test]
fn finalize_with_no_events_emits_nothing() {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, false, false, false);
r.finalize().unwrap();
}
assert_eq!(String::from_utf8(buf).unwrap(), "");
}
#[test]
fn finalize_is_idempotent() {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, false, false, false);
r.push_status(pkg_missing("x", None)).unwrap();
r.finalize().unwrap();
r.finalize().unwrap();
}
assert_eq!(String::from_utf8(buf).unwrap(), "✗ x missing\n");
}
#[test]
fn color_on_wraps_marker_path_and_description_with_expected_escapes() {
let s = generated(Some("a\n"), "b\n", "a.toml", FileStatus::Modified);
let got = render_status(s, true, false);
assert!(got.contains("\x1b[33m~\x1b[0m"), "{got:?}");
assert!(got.contains("\x1b[2m~/a.toml\x1b[0m"), "{got:?}");
assert!(got.contains("\x1b[33mmodified\x1b[0m"), "{got:?}");
}
#[test]
fn color_on_pkg_missing_install_command_is_bold_yellow() {
let got = render_status(pkg_missing("py", Some("brew install py")), true, false);
assert!(got.contains("\x1b[1;33mbrew install py\x1b[0m"), "{got:?}",);
}
#[test]
fn ok_description_is_green_with_color_on() {
let s = generated(Some("x\n"), "x\n", "a.toml", FileStatus::Ok);
let got = render_status_full(s, true, false, true);
assert!(got.contains("\x1b[32m✓\x1b[0m"), "{got:?}");
assert!(got.contains("\x1b[32mok\x1b[0m"), "{got:?}");
}
#[test]
fn clean_description_is_green_with_color_on() {
let s = Status::GitRepoClean {
repo: zenops_path(""),
};
let got = render_status_full(s, true, false, true);
assert!(got.contains("\x1b[32m✓\x1b[0m"), "{got:?}");
assert!(got.contains("\x1b[32mclean\x1b[0m"), "{got:?}");
}
#[test]
fn symlink_ok_splits_zenops_prefix_and_bolds_arrow() {
let s = Status::Symlink {
real: zenops_path("configs/helix/config.toml"),
symlink: home_path(".config/helix/config.toml"),
status: SymlinkStatus::Ok,
};
let got = render_status_full(s, true, false, true);
assert!(
got.contains("\x1b[2m~/.config/helix/config.toml\x1b[0m"),
"{got:?}",
);
assert!(got.contains("\x1b[1m → \x1b[0m"), "{got:?}");
assert!(
got.contains("\x1b[2;38;5;248m~/.config/zenops\x1b[0m"),
"{got:?}",
);
assert!(got.contains("/configs/helix/config.toml\x1b[0m"), "{got:?}");
assert!(got.contains("\x1b[32mok\x1b[0m"), "{got:?}");
}
#[test]
fn git_row_splits_zenops_prefix() {
let repo = zenops_path("");
let s = Status::Git {
repo,
status: GitFileStatus::Modified(relpath("configs/helix/config.toml")),
};
let got = render_status(s, true, false);
assert!(
got.contains("\x1b[2;38;5;248m~/.config/zenops\x1b[0m"),
"{got:?}",
);
assert!(got.contains("/configs/helix/config.toml\x1b[0m"), "{got:?}");
assert!(
!got.contains("\x1b[2m/configs/helix/config.toml"),
"tail should not be dim: {got:?}",
);
}
#[test]
fn path_column_padding_matches_visible_width_for_split_paths() {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, true, false, false);
r.push_status(Status::Git {
repo: zenops_path(""),
status: GitFileStatus::Modified(relpath("a.toml")),
})
.unwrap();
r.push_status(generated(
Some("a\n"),
"b\n",
"long/nested/path/file.toml",
FileStatus::Modified,
))
.unwrap();
r.finalize().unwrap();
}
let got = String::from_utf8(buf).unwrap();
let stripped: String = {
let mut out = String::new();
let mut in_esc = false;
for c in got.chars() {
if in_esc {
if c == 'm' {
in_esc = false;
}
continue;
}
if c == '\x1b' {
in_esc = true;
continue;
}
out.push(c);
}
out
};
let lines: Vec<&str> = stripped.lines().collect();
assert_eq!(lines.len(), 2, "{stripped:?}");
let short_visible = "~/.config/zenops/a.toml".chars().count();
let long_visible = "~/long/nested/path/file.toml".chars().count();
let pad = long_visible - short_visible;
let expected_short = format!("M ~/.config/zenops/a.toml{} modified", " ".repeat(pad));
let expected_long = "~ ~/long/nested/path/file.toml modified";
assert_eq!(lines[0], expected_short, "{stripped:?}");
assert_eq!(lines[1], expected_long, "{stripped:?}");
}
fn json_line_for_status(status: Status) -> serde_json::Value {
let mut buf: Vec<u8> = Vec::new();
JsonOutput::new(&mut buf).push_status(status).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.ends_with('\n'), "JSON line must end with newline: {s:?}");
assert_eq!(
s.matches('\n').count(),
1,
"expected exactly one line: {s:?}"
);
serde_json::from_str(s.trim_end()).unwrap()
}
fn json_line_for_action(action: AppliedAction) -> serde_json::Value {
let mut buf: Vec<u8> = Vec::new();
JsonOutput::new(&mut buf)
.push_applied_action(action)
.unwrap();
let s = String::from_utf8(buf).unwrap();
serde_json::from_str(s.trim_end()).unwrap()
}
#[test]
fn json_status_generated_tags_event_and_kind() {
let v = json_line_for_status(generated(
Some("a\n"),
"b\n",
"alpha.toml",
FileStatus::Modified,
));
assert_eq!(v["event"], "status");
assert_eq!(v["kind"], "generated");
assert_eq!(v["want_content"], "b\n");
assert_eq!(v["cur_content"], "a\n");
assert_eq!(v["status"], "modified");
}
#[test]
fn json_status_symlink_wrong_link_preserves_target_path() {
let v = json_line_for_status(symlink(
"src",
"dst",
SymlinkStatus::WrongLink(PathBuf::from("/other")),
));
assert_eq!(v["event"], "status");
assert_eq!(v["kind"], "symlink");
assert_eq!(v["status"]["kind"], "wrong_link");
assert_eq!(v["status"]["data"], "/other");
}
#[test]
fn json_status_git_tags_nested_git_status_kind() {
let repo = zenops_path("");
let v = json_line_for_status(Status::Git {
repo,
status: GitFileStatus::Untracked(relpath("x.toml")),
});
assert_eq!(v["event"], "status");
assert_eq!(v["kind"], "git");
assert_eq!(v["status"]["kind"], "untracked");
assert_eq!(v["status"]["data"], "x.toml");
}
#[test]
fn json_status_pkg_missing_preserves_install_command() {
let v = json_line_for_status(pkg_missing("python", Some("brew install python")));
assert_eq!(v["event"], "status");
assert_eq!(v["kind"], "pkg");
assert_eq!(v["pkg"], "python");
assert_eq!(v["status"]["kind"], "missing");
assert_eq!(
v["status"]["data"]["install_command"],
"brew install python"
);
}
#[test]
fn json_status_pkg_ok_tags_kind_ok() {
let v = json_line_for_status(pkg_ok("python"));
assert_eq!(v["event"], "status");
assert_eq!(v["kind"], "pkg");
assert_eq!(v["pkg"], "python");
assert_eq!(v["status"]["kind"], "ok");
}
#[test]
fn json_status_git_repo_clean_emits_event() {
let repo = zenops_path("");
let v = json_line_for_status(Status::GitRepoClean { repo });
assert_eq!(v["event"], "status");
assert_eq!(v["kind"], "git_repo_clean");
assert_eq!(v["repo"]["path"]["path"], "");
}
#[test]
fn json_applied_action_tags_event_and_kind() {
let v = json_line_for_action(AppliedAction::CreatedFile(home_path("a.toml")));
assert_eq!(v["event"], "applied_action");
assert_eq!(v["kind"], "created_file");
}
#[test]
fn json_multiple_events_produce_jsonl() {
let mut buf: Vec<u8> = Vec::new();
{
let mut out = JsonOutput::new(&mut buf);
out.push_status(pkg_missing("python", None)).unwrap();
out.push_applied_action(AppliedAction::CreatedDir(home_path("d")))
.unwrap();
}
let s = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = s.lines().collect();
assert_eq!(lines.len(), 2, "{s:?}");
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(first["event"], "status");
assert_eq!(second["event"], "applied_action");
}
fn pkg_entry_pkg(name: &'static str, state: PkgEntryState) -> PkgEntry {
PkgEntry::Pkg {
name: SmolStr::new_static(name),
key: SmolStr::new_static(name),
description: None,
state,
matched_detect: None,
install_hints: PkgInstallHints::default(),
}
}
fn render_pkg_entries(entries: Vec<PkgEntry>, color: bool) -> String {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, color, false, false);
for e in entries {
r.push_pkg_entry(e).unwrap();
}
r.finalize().unwrap();
}
String::from_utf8(buf).unwrap()
}
#[test]
fn pkg_entries_pad_name_column_to_widest() {
let got = render_pkg_entries(
vec![
pkg_entry_pkg("py", PkgEntryState::Missing),
pkg_entry_pkg("starship", PkgEntryState::Installed),
],
false,
);
let lines: Vec<&str> = got.lines().collect();
assert_eq!(lines[0], "✗ py ", "{got:?}");
assert_eq!(lines[1], "✓ starship", "{got:?}");
}
#[test]
fn pkg_entry_disabled_uses_dash_marker() {
let got = render_pkg_entries(vec![pkg_entry_pkg("ghost", PkgEntryState::Disabled)], false);
assert!(got.starts_with("- ghost"), "{got:?}");
}
#[test]
fn pkg_entry_missing_with_brew_hint_renders_indented_hint_line() {
let got = render_pkg_entries(
vec![PkgEntry::Pkg {
name: SmolStr::new_static("foo"),
key: SmolStr::new_static("foo"),
description: None,
state: PkgEntryState::Missing,
matched_detect: None,
install_hints: PkgInstallHints {
brew: vec!["foo-formula".into()],
},
}],
false,
);
assert!(got.contains("✗ foo"), "{got:?}");
assert!(got.contains("brew: foo-formula"), "{got:?}");
}
#[test]
fn pkg_aggregate_install_renders_blank_line_then_footer() {
let got = render_pkg_entries(
vec![
pkg_entry_pkg("foo", PkgEntryState::Missing),
PkgEntry::AggregateInstall {
pkg_manager: "brew".into(),
command: "brew install foo".into(),
packages: vec!["foo".into()],
},
],
false,
);
let lines: Vec<&str> = got.lines().collect();
let footer_idx = lines
.iter()
.position(|l| l.contains("To install all missing"))
.expect("expected footer line");
assert_eq!(lines[footer_idx - 1], "", "{got:?}");
assert!(
lines[footer_idx].contains("via brew: brew install foo"),
"{got:?}",
);
}
#[test]
fn pkg_no_manager_warning_renders_inline_before_pkg_block() {
let got = render_pkg_entries(
vec![
PkgEntry::NoPackageManagerDetected {
supported: vec!["brew".into()],
},
pkg_entry_pkg("foo", PkgEntryState::Missing),
],
false,
);
let lines: Vec<&str> = got.lines().collect();
assert!(lines[0].contains("no known package manager"), "{got:?}");
assert!(lines[0].contains("Supported managers: brew"), "{got:?}");
assert!(lines[1].contains("foo"), "{got:?}");
}
fn render_doctor_checks(checks: Vec<DoctorCheck>, color: bool) -> String {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, color, false, false);
for c in checks {
r.push_doctor_check(c).unwrap();
}
r.finalize().unwrap();
}
String::from_utf8(buf).unwrap()
}
fn doctor_check(
section: DoctorSection,
label: &'static str,
severity: DoctorSeverity,
value: &str,
hint: Option<&str>,
) -> DoctorCheck {
DoctorCheck::Check {
section,
label: SmolStr::new_static(label),
severity,
value: value.to_string(),
hint: hint.map(String::from),
detail: Vec::new(),
}
}
#[test]
fn doctor_check_groups_by_section_with_blank_separator() {
let got = render_doctor_checks(
vec![
DoctorCheck::SectionHeader {
section: DoctorSection::System,
},
doctor_check(
DoctorSection::System,
"os:",
DoctorSeverity::Info,
"macos",
None,
),
DoctorCheck::SectionHeader {
section: DoctorSection::Repo,
},
doctor_check(
DoctorSection::Repo,
"git repo:",
DoctorSeverity::Ok,
"yes",
None,
),
],
false,
);
let want = "System\n os: macos\n\nConfig repo (~/.config/zenops)\n git repo: yes\n";
assert_eq!(got, want, "{got:?}");
}
#[test]
fn doctor_check_with_hint_renders_hint_after_value() {
let got = render_doctor_checks(
vec![doctor_check(
DoctorSection::System,
"git:",
DoctorSeverity::Bad,
"not found on PATH",
Some("install git"),
)],
false,
);
assert!(got.contains("git:"), "{got:?}");
assert!(got.contains("not found on PATH"), "{got:?}");
assert!(got.contains("install git"), "{got:?}");
}
#[test]
fn doctor_check_with_detail_indents_each_line_under_row() {
let got = render_doctor_checks(
vec![DoctorCheck::Check {
section: DoctorSection::Config,
label: SmolStr::new_static("status:"),
severity: DoctorSeverity::Bad,
value: "parse error".into(),
hint: None,
detail: vec!["/path/to/config.toml".into(), "expected `]`".into()],
}],
false,
);
assert!(got.contains(" /path/to/config.toml\n"), "{got:?}");
assert!(got.contains(" expected `]`\n"), "{got:?}");
}
#[test]
fn init_summary_renders_summary_with_remote_shell_and_pkg_count() {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, false, false, false);
r.push_init_summary(InitSummary {
clone_path: PathBuf::from("/home/test/.config/zenops"),
remote: Some("git@example.com:cfg.git".into()),
shell: Some("bash".into()),
pkg_count: 12,
})
.unwrap();
r.finalize().unwrap();
}
let got = String::from_utf8(buf).unwrap();
assert!(
got.contains("Cloned into /home/test/.config/zenops"),
"{got:?}",
);
assert!(got.contains("remote: git@example.com:cfg.git"), "{got:?}");
assert!(got.contains("shell: bash"), "{got:?}");
assert!(got.contains("pkgs: 12"), "{got:?}");
assert!(got.contains("Next: run `zenops apply`"), "{got:?}");
}
fn json_line_for_pkg_entry(entry: PkgEntry) -> serde_json::Value {
let mut buf: Vec<u8> = Vec::new();
JsonOutput::new(&mut buf).push_pkg_entry(entry).unwrap();
let s = String::from_utf8(buf).unwrap();
serde_json::from_str(s.trim_end()).unwrap()
}
#[test]
fn json_pkg_entry_pkg_tags_event_and_kind_with_state() {
let v = json_line_for_pkg_entry(PkgEntry::Pkg {
name: SmolStr::new_static("starship"),
key: SmolStr::new_static("starship"),
description: Some("cross-shell prompt".into()),
state: PkgEntryState::Missing,
matched_detect: None,
install_hints: PkgInstallHints {
brew: vec!["starship".into()],
},
});
assert_eq!(v["event"], "pkg_entry");
assert_eq!(v["kind"], "pkg");
assert_eq!(v["name"], "starship");
assert_eq!(v["key"], "starship");
assert_eq!(v["state"], "missing");
assert_eq!(v["install_hints"]["brew"][0], "starship");
}
#[test]
fn json_pkg_entry_aggregate_install_carries_command_and_packages() {
let v = json_line_for_pkg_entry(PkgEntry::AggregateInstall {
pkg_manager: "brew".into(),
command: "brew install foo bar".into(),
packages: vec!["foo".into(), "bar".into()],
});
assert_eq!(v["event"], "pkg_entry");
assert_eq!(v["kind"], "aggregate_install");
assert_eq!(v["pkg_manager"], "brew");
assert_eq!(v["command"], "brew install foo bar");
assert_eq!(v["packages"][0], "foo");
assert_eq!(v["packages"][1], "bar");
}
#[test]
fn json_pkg_entry_no_manager_warning_is_event() {
let v = json_line_for_pkg_entry(PkgEntry::NoPackageManagerDetected {
supported: vec!["brew".into()],
});
assert_eq!(v["event"], "pkg_entry");
assert_eq!(v["kind"], "no_package_manager_detected");
assert_eq!(v["supported"][0], "brew");
}
fn json_line_for_doctor_check(check: DoctorCheck) -> Option<serde_json::Value> {
let mut buf: Vec<u8> = Vec::new();
JsonOutput::new(&mut buf).push_doctor_check(check).unwrap();
let s = String::from_utf8(buf).unwrap();
if s.is_empty() {
None
} else {
Some(serde_json::from_str(s.trim_end()).unwrap())
}
}
#[test]
fn json_doctor_check_includes_section_severity_label_value() {
let v = json_line_for_doctor_check(doctor_check(
DoctorSection::System,
"os:",
DoctorSeverity::Info,
"linux",
None,
))
.expect("Check variant should emit JSON");
assert_eq!(v["event"], "doctor_check");
assert_eq!(v["kind"], "check");
assert_eq!(v["section"], "system");
assert_eq!(v["label"], "os:");
assert_eq!(v["severity"], "info");
assert_eq!(v["value"], "linux");
}
#[test]
fn json_doctor_check_section_header_is_skipped() {
let v = json_line_for_doctor_check(DoctorCheck::SectionHeader {
section: DoctorSection::Packages,
});
assert!(
v.is_none(),
"section header should not produce a JSON line, got: {v:?}",
);
}
#[test]
fn json_init_summary_includes_all_fields() {
let mut buf: Vec<u8> = Vec::new();
JsonOutput::new(&mut buf)
.push_init_summary(InitSummary {
clone_path: PathBuf::from("/home/test/.config/zenops"),
remote: Some("git@example.com:cfg.git".into()),
shell: Some("zsh".into()),
pkg_count: 7,
})
.unwrap();
let s = String::from_utf8(buf).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim_end()).unwrap();
assert_eq!(v["event"], "init_summary");
assert_eq!(v["clone_path"], "/home/test/.config/zenops");
assert_eq!(v["remote"], "git@example.com:cfg.git");
assert_eq!(v["shell"], "zsh");
assert_eq!(v["pkg_count"], 7);
}
#[test]
fn terminal_renderer_flushes_status_block_before_pkg_block() {
let mut buf: Vec<u8> = Vec::new();
{
let mut r = TerminalRenderer::new(&mut buf, false, false, false);
r.push_status(pkg_missing("py", None)).unwrap();
r.push_pkg_entry(pkg_entry_pkg("foo", PkgEntryState::Missing))
.unwrap();
r.finalize().unwrap();
}
let got = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = got.lines().collect();
assert!(lines[0].starts_with("✗ py"), "{got:?}");
assert!(lines[1].starts_with("✗ foo"), "{got:?}");
}
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("boom"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn terminal_renderer_surfaces_writer_errors_on_finalize() {
let mut w = FailingWriter;
let mut r = TerminalRenderer::new(&mut w, false, false, false);
r.push_status(pkg_missing("x", None)).unwrap();
let err = r.finalize().unwrap_err();
assert!(matches!(err, OutputError::Io(_)), "unexpected: {err:?}");
}
#[test]
fn json_output_surfaces_writer_errors() {
let mut w = FailingWriter;
let err = JsonOutput::new(&mut w)
.push_status(pkg_missing("x", None))
.unwrap_err();
assert!(
matches!(err, OutputError::Io(_) | OutputError::Json(_)),
"unexpected: {err:?}",
);
}
}