use std::env;
use std::path::{Path, PathBuf};
use anstyle::{AnsiColor, Style};
use comfy_table::{Cell, Color as TableColor, ContentArrangement, Table, presets::UTF8_FULL};
use indexmap::IndexMap;
use serde::Serialize;
use unicode_width::UnicodeWidthStr;
use crate::surface::dto::{
ConfigCommentResult, DiagnosticRecord, LakeCheckResult, PathRecord, QueryColumn, QueryColumns,
QueryOutputFormat, QueryResults, RenderResult, SkillWrapperRecord, StatusCommitBlocker,
StatusCommitState, StatusFrost, StatusFrostState, StatusResult,
};
use crate::surface::error::CommandError;
use crate::{Entry, EntryDirectoryError, EntryDirectoryReport, WitnessRecord};
pub fn format_json<T: Serialize + ?Sized>(value: &T) -> Result<String, CommandError> {
Ok(serde_json::to_string_pretty(value)?)
}
pub(crate) fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), CommandError> {
println!("{}", format_json(value)?);
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OutputStyle {
Plain,
Styled,
#[cfg(test)]
Forced,
}
impl OutputStyle {
fn colors(self) -> bool {
self != Self::Plain
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SemanticStyle {
Changed,
Error,
Muted,
Success,
Warning,
}
impl SemanticStyle {
fn text_style(self) -> Style {
let color = match self {
| Self::Changed => AnsiColor::Cyan,
| Self::Error => AnsiColor::Red,
| Self::Muted => AnsiColor::BrightBlack,
| Self::Success => AnsiColor::Green,
| Self::Warning => AnsiColor::Yellow,
};
Style::new().fg_color(Some(color.into()))
}
fn table_color(self) -> TableColor {
match self {
| Self::Changed => TableColor::Cyan,
| Self::Error => TableColor::Red,
| Self::Muted => TableColor::DarkGrey,
| Self::Success => TableColor::Green,
| Self::Warning => TableColor::Yellow,
}
}
}
pub(crate) fn print_cli_error(error: &CommandError) {
anstream::eprintln!(
"{} {error}",
style_text("sirno:", SemanticStyle::Error, OutputStyle::Styled)
);
}
pub(crate) fn format_success_text(value: &str, style: OutputStyle) -> String {
style_text(value, SemanticStyle::Success, style)
}
pub(crate) fn format_muted_text(value: &str, style: OutputStyle) -> String {
style_text(value, SemanticStyle::Muted, style)
}
pub(crate) fn format_warning_text(value: &str, style: OutputStyle) -> String {
style_text(value, SemanticStyle::Warning, style)
}
pub(crate) fn print_witness_records(records: &[WitnessRecord], full: bool) {
print!("{}", format_witness_records(records, full));
}
pub(crate) fn format_witness_records(records: &[WitnessRecord], full: bool) -> String {
let mut out = String::new();
for (index, record) in records.iter().enumerate() {
if full && index > 0 {
out.push_str("---\n\n");
}
out.push_str(&format_witness_record(record, full));
}
out
}
pub(crate) fn format_witness_record(record: &WitnessRecord, full: bool) -> String {
let range = format_witness_summary(record);
if !full {
let marker =
record.body.lines().next().map(str::to_owned).unwrap_or_else(|| record.marker.clone());
return format!("{range}\t{marker}\n");
}
let mut out = format!("{range}\n\n");
out.push_str(&record.body);
if !record.body.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out
}
fn format_witness_summary(record: &WitnessRecord) -> String {
format!(
"{}:{}:{}-{} :: {}:{}-{}",
record.path.display(),
record.opening.start_line,
record.opening.start_column,
record.opening.end_column,
record.closing.start_line,
record.closing.start_column,
record.closing.end_column
)
}
pub(crate) fn display_path(path: &Path) -> String {
path.display().to_string()
}
pub(crate) fn display_paths(paths: &[PathBuf]) -> Vec<String> {
paths.iter().map(|path| display_path(path)).collect()
}
pub(crate) fn diagnostics_from_entry_report(
report: &EntryDirectoryReport,
) -> Vec<DiagnosticRecord> {
let mut diagnostics = Vec::new();
for diagnostic in report.file_diagnostics() {
diagnostics.push(DiagnosticRecord {
severity: diagnostic.severity.label().to_owned(),
path: Some(display_path(&diagnostic.path)),
message: diagnostic.message.clone(),
});
}
for diagnostic in report.structural_report().diagnostics() {
diagnostics.push(DiagnosticRecord {
severity: diagnostic.severity.label().to_owned(),
path: diagnostic
.entry
.as_ref()
.and_then(|entry| report.entry_path(entry))
.map(display_path),
message: diagnostic.message(),
});
}
diagnostics
}
pub(crate) fn print_status_result(result: &StatusResult) {
anstream::print!("{}", format_status_result_with_style(result, OutputStyle::Styled));
}
#[cfg(test)]
pub(crate) fn format_status_result(result: &StatusResult) -> String {
format_status_result_with_style(result, OutputStyle::Plain)
}
fn format_status_result_with_style(result: &StatusResult, style: OutputStyle) -> String {
let mut output = String::new();
output.push_str(&format!("config: {}\n", result.config_path));
output.push_str(&format!("lake: {} ({} entries)\n", result.lake_path, result.entry_count));
if let Some(frost) = &result.frost {
output.push_str(&format!("frost: {}\n", status_frost_label(frost, style)));
} else {
output.push_str(&format!(
"frost: {}\n",
style_text("(not configured)", SemanticStyle::Muted, style)
));
}
output.push_str(&format!(
"structure: {} configured {}\n",
result.structural_fields.len(),
plural(result.structural_fields.len(), "field", "fields")
));
let render =
if result.check_policy.render { "render links checked" } else { "render links skipped" };
if !result.check.ok {
output.push_str(&format_diagnostics_with_style(&result.check.diagnostics, style));
}
if result.check.ok {
output.push_str(&format!(
"lake check: {} (review; {render})\n",
style_text("ok", SemanticStyle::Success, style)
));
} else {
output.push_str(&format!(
"lake check: {} (review; {render})\n",
style_text("failed", SemanticStyle::Error, style)
));
}
if let Some(tide) = &result.tide {
if tide.clear {
output.push_str(&format!(
"tide: {}\n",
style_text("clear", SemanticStyle::Success, style)
));
} else {
output.push_str(&format!(
"tide: {} open {} in {} {}, {} review {}\n",
tide.open_workitems,
plural(tide.open_workitems, "workitem", "workitems"),
tide.open_waves,
plural(tide.open_waves, "wave", "waves"),
tide.review_entries,
plural(tide.review_entries, "entry", "entries")
));
}
}
output.push_str(&format!("commit: {}\n", status_commit_label(result, style)));
output
}
fn status_frost_label(frost: &StatusFrost, style: OutputStyle) -> String {
match frost.state {
| StatusFrostState::Unlocked => {
format!("{} ({})", frost.path, style_text("unlocked", SemanticStyle::Muted, style))
}
| StatusFrostState::Current => {
let version = frost.version.map(|version| format!("v{version}")).unwrap_or_default();
format!(
"{} ({})",
frost.path,
style_text(&format!("current {version}"), SemanticStyle::Success, style)
)
}
| StatusFrostState::CheckedOut => {
let version = frost.version.map(|version| format!("v{version}")).unwrap_or_default();
let mutable =
if frost.mutable.unwrap_or(false) { ", unsafe mutable" } else { ", immutable" };
format!(
"{} ({})",
frost.path,
style_text(
&format!("checked-out {version}{mutable}"),
SemanticStyle::Warning,
style,
)
)
}
}
}
fn status_commit_label(result: &StatusResult, style: OutputStyle) -> String {
match result.commit.state {
| StatusCommitState::Ready => style_text("ready", SemanticStyle::Success, style),
| StatusCommitState::Unavailable => {
style_text("unavailable; frost not configured", SemanticStyle::Muted, style)
}
| StatusCommitState::Blocked => {
let detail = result
.commit
.blockers
.iter()
.map(commit_blocker_label)
.collect::<Vec<_>>()
.join("; ");
style_text(&format!("blocked; {detail}"), SemanticStyle::Warning, style)
}
}
}
fn commit_blocker_label(blocker: &StatusCommitBlocker) -> &'static str {
match blocker {
| StatusCommitBlocker::LakeCheck => "fix lake check",
| StatusCommitBlocker::Tide => "run `sirno tide status`",
| StatusCommitBlocker::ImmutableCheckout => "checkout is immutable",
}
}
fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
pub(crate) fn print_lake_check_result(result: &LakeCheckResult) {
anstream::print!("{}", format_lake_check_result_with_style(result, OutputStyle::Styled));
}
#[cfg(test)]
pub(crate) fn format_lake_check_result(result: &LakeCheckResult) -> String {
format_lake_check_result_with_style(result, OutputStyle::Plain)
}
fn format_lake_check_result_with_style(result: &LakeCheckResult, style: OutputStyle) -> String {
if result.diagnostics.is_empty() {
return format!("{}\n", format_ok_line(&result.root, style));
}
let mut output = format_diagnostics_with_style(&result.diagnostics, style);
output.push_str(&format!("{}\n", lake_check_summary(result, style)));
output
}
fn lake_check_summary(result: &LakeCheckResult, style: OutputStyle) -> String {
if result.has_errors {
format!("check: {} in {}", style_text("failed", SemanticStyle::Error, style), result.root)
} else {
format!(
"check: {} in {}",
style_text("warnings", SemanticStyle::Warning, style),
result.root
)
}
}
pub(crate) fn print_render_result(result: &RenderResult) {
anstream::print!("{}", format_render_result_with_style(result, OutputStyle::Styled));
}
#[cfg(test)]
pub(crate) fn format_render_result(result: &RenderResult) -> String {
format_render_result_with_style(result, OutputStyle::Plain)
}
fn format_render_result_with_style(result: &RenderResult, style: OutputStyle) -> String {
if result.diagnostics.is_empty() {
return format!("{}\n", result.message);
}
let mut output = format_diagnostics_with_style(&result.diagnostics, style);
output.push_str(&result.message);
output.push('\n');
output
}
pub(crate) fn print_config_comment_result(result: &ConfigCommentResult) {
anstream::print!("{}", format_config_comment_result_with_style(result, OutputStyle::Styled));
}
#[cfg(test)]
pub(crate) fn format_config_comment_result(result: &ConfigCommentResult) -> String {
format_config_comment_result_with_style(result, OutputStyle::Plain)
}
fn format_config_comment_result_with_style(
result: &ConfigCommentResult, style: OutputStyle,
) -> String {
let mut output = String::new();
if !result.changed {
for comment in &result.missing_comments {
output.push_str(&style_text("missing:", SemanticStyle::Warning, style));
output.push(' ');
output.push_str(comment);
output.push('\n');
}
}
output.push_str(&result.message);
output.push('\n');
output
}
fn format_diagnostics_with_style(diagnostics: &[DiagnosticRecord], style: OutputStyle) -> String {
let mut output = String::new();
for diagnostic in diagnostics {
let severity = styled_diagnostic_severity(&diagnostic.severity, style);
if let Some(path) = &diagnostic.path {
output.push_str(&format!("{severity}: {path}: {}\n", diagnostic.message));
} else {
output.push_str(&format!("{severity}: {}\n", diagnostic.message));
}
}
output
}
fn styled_diagnostic_severity(severity: &str, style: OutputStyle) -> String {
match severity {
| "error" => style_text(severity, SemanticStyle::Error, style),
| "warning" => style_text(severity, SemanticStyle::Warning, style),
| _ => severity.to_owned(),
}
}
fn style_text(value: &str, semantic: SemanticStyle, style: OutputStyle) -> String {
if !style.colors() {
return value.to_owned();
}
let text_style = semantic.text_style();
format!("{text_style}{value}{text_style:#}")
}
pub(crate) fn format_gen_link_report(
root: &Path, entry_count: usize, changed_paths: &[PathBuf],
) -> String {
if changed_paths.is_empty() {
return format!("No changes in {}", root.display());
}
let mut report = String::new();
for path in changed_paths {
report.push_str("- ");
report.push_str(&path.display().to_string());
report.push('\n');
}
report.push_str("Total changes: ");
report.push_str(&changed_paths.len().to_string());
report.push('/');
report.push_str(&entry_count.to_string());
report.push_str(" in ");
report.push_str(&root.display().to_string());
report
}
pub(crate) fn print_query_results(
results: &QueryResults, format: QueryOutputFormat,
) -> Result<(), CommandError> {
match format {
| QueryOutputFormat::Json => {
println!("{}", results.to_json()?);
}
| QueryOutputFormat::Human => {
print!("{}", format_query_table(&results.columns, &results.rows));
}
}
Ok(())
}
pub(crate) fn output_path(path: PathBuf, absolute: bool) -> Result<PathBuf, CommandError> {
if !absolute || path.is_absolute() {
return Ok(path);
}
Ok(env::current_dir().map_err(CommandError::CurrentDirectory)?.join(path))
}
pub(crate) fn format_path_table(records: &[PathRecord]) -> String {
let headers = ["kind", "path"];
let rows = records.iter().map(|record| [record.kind, record.path.as_str()]);
format_human_table(headers, rows)
}
#[cfg(test)]
pub(crate) fn format_skill_wrapper_table(records: &[SkillWrapperRecord]) -> String {
format_skill_wrapper_table_with_style(records, OutputStyle::Plain)
}
pub(crate) fn format_skill_wrapper_table_for_terminal(records: &[SkillWrapperRecord]) -> String {
format_skill_wrapper_table_with_style(records, OutputStyle::Styled)
}
fn format_skill_wrapper_table_with_style(
records: &[SkillWrapperRecord], style: OutputStyle,
) -> String {
let headers = ["status", "name", "target"];
let rows = records
.iter()
.map(|record| [record.status.as_str(), record.name.as_str(), record.target_path.as_str()]);
format_human_table_with_style(headers, rows, style)
}
pub(crate) fn query_result_rows(
report: &EntryDirectoryReport, entries: &[&Entry], columns: &QueryColumns,
) -> Result<Vec<Vec<String>>, CommandError> {
entries
.iter()
.map(|entry| {
columns
.columns
.iter()
.map(|column| format_query_column(report, entry, *column))
.collect::<Result<Vec<_>, _>>()
})
.collect()
}
fn format_query_column(
report: &EntryDirectoryReport, entry: &Entry, column: QueryColumn,
) -> Result<String, CommandError> {
match column {
| QueryColumn::Id => Ok(entry.id.to_string()),
| QueryColumn::Name => Ok(entry.metadata.name.clone()),
| QueryColumn::Path => {
let path = report
.entry_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryPath(entry.id.clone()))?;
Ok(path.display().to_string())
}
| QueryColumn::Desc => Ok(entry.metadata.desc.clone()),
}
}
pub(crate) fn format_query_json(
columns: &QueryColumns, rows: &[Vec<String>],
) -> Result<String, CommandError> {
format_json(&query_result_records(columns, rows))
}
pub(crate) fn query_result_records(
columns: &QueryColumns, rows: &[Vec<String>],
) -> Vec<IndexMap<String, String>> {
rows.iter()
.map(|row| {
columns
.columns
.iter()
.zip(row)
.map(|(column, value)| (column.label().to_owned(), value.clone()))
.collect()
})
.collect()
}
pub(crate) fn format_query_table(columns: &QueryColumns, rows: &[Vec<String>]) -> String {
let headers = columns.columns.iter().map(|column| column.label()).collect::<Vec<_>>();
format_human_table(headers, rows.iter().map(|row| row.iter().map(String::as_str)))
}
fn format_human_table<'a>(
headers: impl IntoIterator<Item = &'a str>,
rows: impl IntoIterator<Item = impl IntoIterator<Item = &'a str>>,
) -> String {
format_human_table_with_style(headers, rows, OutputStyle::Plain)
}
fn format_human_table_with_style<'a>(
headers: impl IntoIterator<Item = &'a str>,
rows: impl IntoIterator<Item = impl IntoIterator<Item = &'a str>>, style: OutputStyle,
) -> String {
let headers = headers.into_iter().map(str::to_owned).collect::<Vec<_>>();
let rows = rows
.into_iter()
.map(|row| row.into_iter().map(str::to_owned).collect::<Vec<_>>())
.collect::<Vec<_>>();
format_human_table_with_width_and_style(headers, rows, None, style)
}
#[cfg(test)]
pub(crate) fn format_human_table_with_width(
headers: Vec<String>, rows: Vec<Vec<String>>, width: Option<u16>,
) -> String {
format_human_table_with_width_and_style(headers, rows, width, OutputStyle::Plain)
}
pub(crate) fn format_human_table_semantic_with_width(
headers: Vec<String>, rows: Vec<Vec<String>>, width: Option<u16>, style: OutputStyle,
) -> String {
format_human_table_with_width_and_style(headers, rows, width, style)
}
fn format_human_table_with_width_and_style(
headers: Vec<String>, rows: Vec<Vec<String>>, width: Option<u16>, style: OutputStyle,
) -> String {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_content_arrangement(ContentArrangement::Dynamic);
if let Some(width) = width {
table.set_width(width);
}
#[cfg(test)]
if style == OutputStyle::Forced {
table.enforce_styling();
}
let (headers, rows) = elide_human_table_columns(headers, rows, table.width());
let styled_rows = rows
.into_iter()
.map(|row| {
row.into_iter()
.enumerate()
.map(|(index, cell)| {
let header = headers.get(index).map(String::as_str).unwrap_or_default();
semantic_cell(header, cell, style)
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
table.set_header(headers);
table.add_rows(styled_rows);
let mut output = table.to_string();
output.push('\n');
output
}
fn semantic_cell(header: &str, value: String, style: OutputStyle) -> Cell {
let cell = Cell::new(value.clone());
if let Some(semantic) = semantic_table_cell_style(header, &value).filter(|_| style.colors()) {
cell.fg(semantic.table_color())
} else {
cell
}
}
fn semantic_table_cell_style(header: &str, value: &str) -> Option<SemanticStyle> {
match header {
| "state" | "status" => semantic_status_style(value),
| _ => None,
}
}
fn semantic_status_style(value: &str) -> Option<SemanticStyle> {
match value {
| "ok" | "resolved" | "unchanged" => Some(SemanticStyle::Success),
| "drifted" | "missing" | "open" => Some(SemanticStyle::Warning),
| "linked" | "wrote" => Some(SemanticStyle::Changed),
| _ => None,
}
}
fn elide_human_table_columns(
headers: Vec<String>, rows: Vec<Vec<String>>, width: Option<u16>,
) -> (Vec<String>, Vec<Vec<String>>) {
let Some(width) = width.map(usize::from) else {
return (headers, rows);
};
if headers.len() <= 2 || min_table_width(&headers) <= width {
return (headers, rows);
}
for visible in (1..headers.len()).rev() {
let mut candidate_headers = headers.iter().take(visible).cloned().collect::<Vec<_>>();
candidate_headers.push("...".to_owned());
if min_table_width(&candidate_headers) <= width {
let candidate_rows = rows
.into_iter()
.map(|row| {
let mut cells = row.into_iter().take(visible).collect::<Vec<_>>();
cells.push("...".to_owned());
cells
})
.collect();
return (candidate_headers, candidate_rows);
}
}
(
headers.into_iter().take(1).collect(),
rows.into_iter().map(|row| row.into_iter().take(1).collect()).collect(),
)
}
fn min_table_width(headers: &[String]) -> usize {
headers.iter().map(|header| UnicodeWidthStr::width(header.as_str()).max(1)).sum::<usize>()
+ headers.len() * 3
+ 1
}
pub(crate) fn print_entry_directory_report(report: &EntryDirectoryReport) {
if report.is_clean() {
print_ok_path(report.root());
return;
}
anstream::print!("{}", format_entry_directory_report_with_style(report, OutputStyle::Styled));
}
fn format_entry_directory_report_with_style(
report: &EntryDirectoryReport, style: OutputStyle,
) -> String {
let mut output = String::new();
for diagnostic in report.file_diagnostics() {
output.push_str(&format!(
"{}: {}: {}\n",
styled_diagnostic_severity(diagnostic.severity.label(), style),
diagnostic.path.display(),
diagnostic.message
));
}
for diagnostic in report.structural_report().diagnostics() {
if let Some(path) = diagnostic.entry.as_ref().and_then(|entry| report.entry_path(entry)) {
output.push_str(&format!(
"{}: {}: {}\n",
styled_diagnostic_severity(diagnostic.severity.label(), style),
path.display(),
diagnostic.message()
));
} else {
output.push_str(&format!(
"{}: {}\n",
styled_diagnostic_severity(diagnostic.severity.label(), style),
diagnostic.message()
));
}
}
output.push_str(&format!("{}\n", entry_directory_report_summary(report, style)));
output
}
fn entry_directory_report_summary(report: &EntryDirectoryReport, style: OutputStyle) -> String {
if report.has_errors() {
format!(
"check: {} in {}",
style_text("failed", SemanticStyle::Error, style),
report.root().display()
)
} else {
format!(
"check: {} in {}",
style_text("warnings", SemanticStyle::Warning, style),
report.root().display()
)
}
}
pub(crate) fn print_ok_path(path: &Path) {
anstream::println!("{}", format_ok_line(&path.display().to_string(), OutputStyle::Styled));
}
pub(crate) fn print_check_diagnostic(severity: &str, message: &str) {
let style = OutputStyle::Styled;
anstream::println!("{}: {message}", styled_diagnostic_severity(severity, style));
}
pub(crate) fn print_check_summary(has_errors: bool, root: &Path) {
let style = OutputStyle::Styled;
let label = if has_errors {
style_text("failed", SemanticStyle::Error, style)
} else {
style_text("warnings", SemanticStyle::Warning, style)
};
anstream::println!("check: {label} in {}", root.display());
}
fn format_ok_line(location: &str, style: OutputStyle) -> String {
format!("{}: {location}", style_text("ok", SemanticStyle::Success, style))
}