use std::env;
use std::path::{Path, PathBuf};
use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL};
use indexmap::IndexMap;
use serde::Serialize;
use unicode_width::UnicodeWidthStr;
use crate::core::dto::{
ConfigCommentResult, DiagnosticRecord, LakeCheckResult, PathRecord, QueryColumn, QueryColumns,
QueryOutputFormat, QueryResults, RenderResult, SkillWrapperRecord, StatusResult,
};
use crate::core::error::CommandError;
use crate::{
Entry, EntryDirectoryError, EntryDirectoryReport, FrostLockStatus, SirnoLock, 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(())
}
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: report.entry_path(&diagnostic.entry).map(display_path),
message: diagnostic.message(),
});
}
diagnostics
}
pub(crate) fn print_status_result(result: &StatusResult) {
println!("config: {}", result.config_path);
if let Some(mono) = &result.mono_path {
println!("mono: {mono}");
} else {
println!("mono: (not configured)");
}
println!("lake: {}", result.lake_path);
if let Some(frost) = &result.frost_path {
println!("frost: {frost}");
println!("frost-state: {}", result.frost_state);
} else {
println!("frost: (not configured)");
}
println!("entries: {}", result.entry_count);
println!("checks:");
println!(" render: {}", result.check_render);
println!("structural:");
for field in &result.structural_fields {
println!(" {}.to: {}", field.field, field.to);
println!(" {}.from: {}", field.field, field.from);
println!(" {}.clique: {}", field.field, field.clique);
}
if result.ok {
println!("check: ok");
} else {
print_diagnostics(&result.check.diagnostics);
println!("check: failed");
}
}
pub(crate) fn print_lake_check_result(result: &LakeCheckResult) {
print!("{}", format_lake_check_result(result));
}
pub(crate) fn format_lake_check_result(result: &LakeCheckResult) -> String {
if result.diagnostics.is_empty() {
return format!("ok: {}\n", result.root);
}
let mut output = format_diagnostics(&result.diagnostics);
output.push_str(&format!("{}\n", lake_check_summary(result)));
output
}
fn lake_check_summary(result: &LakeCheckResult) -> String {
if result.has_errors {
format!("check: failed in {}", result.root)
} else {
format!("check: warnings in {}", result.root)
}
}
pub(crate) fn print_render_result(result: &RenderResult) {
print!("{}", format_render_result(result));
}
pub(crate) fn format_render_result(result: &RenderResult) -> String {
if result.diagnostics.is_empty() {
return format!("{}\n", result.message);
}
let mut output = format_diagnostics(&result.diagnostics);
output.push_str(&result.message);
output.push('\n');
output
}
pub(crate) fn print_config_comment_result(result: &ConfigCommentResult) {
print!("{}", format_config_comment_result(result));
}
pub(crate) fn format_config_comment_result(result: &ConfigCommentResult) -> String {
let mut output = String::new();
if !result.changed {
for comment in &result.missing_comments {
output.push_str("missing: ");
output.push_str(comment);
output.push('\n');
}
}
output.push_str(&result.message);
output.push('\n');
output
}
fn print_diagnostics(diagnostics: &[DiagnosticRecord]) {
print!("{}", format_diagnostics(diagnostics));
}
fn format_diagnostics(diagnostics: &[DiagnosticRecord]) -> String {
let mut output = String::new();
for diagnostic in diagnostics {
if let Some(path) = &diagnostic.path {
output
.push_str(&format!("{}: {}: {}\n", diagnostic.severity, path, diagnostic.message));
} else {
output.push_str(&format!("{}: {}\n", diagnostic.severity, diagnostic.message));
}
}
output
}
pub(crate) fn frost_state_label(lock: Option<&SirnoLock>) -> String {
let Some(lock) = lock else {
return "(unlocked)".to_owned();
};
match lock.frost.status {
| FrostLockStatus::Current => {
format!(
"current version {} (generation {}, mutable)",
lock.frost.version, lock.frost.generation
)
}
| FrostLockStatus::CheckedOut if lock.frost.mutable => {
format!(
"checked-out version {} (generation {}, unsafe mutable)",
lock.frost.version, lock.frost.generation
)
}
| FrostLockStatus::CheckedOut => {
format!(
"checked-out version {} (generation {}, immutable)",
lock.frost.version, lock.frost.generation
)
}
}
}
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)
}
pub(crate) fn format_skill_wrapper_table(records: &[SkillWrapperRecord]) -> 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(headers, rows)
}
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 {
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(headers, rows, None)
}
pub(crate) fn format_human_table_with_width(
headers: Vec<String>, rows: Vec<Vec<String>>, width: Option<u16>,
) -> 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);
}
let (headers, rows) = elide_human_table_columns(headers, rows, table.width());
table.set_header(headers);
table.add_rows(rows);
let mut output = table.to_string();
output.push('\n');
output
}
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() {
println!("ok: {}", report.root().display());
return;
}
print!("{}", format_entry_directory_report(report));
}
fn format_entry_directory_report(report: &EntryDirectoryReport) -> String {
let mut output = String::new();
for diagnostic in report.file_diagnostics() {
output.push_str(&format!(
"{}: {}: {}\n",
diagnostic.severity.label(),
diagnostic.path.display(),
diagnostic.message
));
}
for diagnostic in report.structural_report().diagnostics() {
if let Some(path) = report.entry_path(&diagnostic.entry) {
output.push_str(&format!(
"{}: {}: {}\n",
diagnostic.severity.label(),
path.display(),
diagnostic.message()
));
} else {
output.push_str(&format!(
"{}: {}\n",
diagnostic.severity.label(),
diagnostic.message()
));
}
}
output.push_str(&format!("{}\n", entry_directory_report_summary(report)));
output
}
fn entry_directory_report_summary(report: &EntryDirectoryReport) -> String {
if report.has_errors() {
format!("check: failed in {}", report.root().display())
} else {
format!("check: warnings in {}", report.root().display())
}
}