/**
* Docker Compose formatting utilities
*
* Provides consistent markdown formatting for Compose project
* listing and status display.
* Follows docs/STYLE.md canonical output templates.
*/
import type { ComposeProject } from "../types.js";
import {
COMPOSE_PROJECT_SEVERITY,
COMPOSE_SERVICE_SEVERITY,
sortBySeverity,
} from "../utils/sorting.js";
import { getTimestamp } from "./utils.js";
/**
* Get canonical state symbol for compose project status
* STYLE.md Section 4.1: Status and State symbols
*/
function getStatusSymbol(status: string): string {
if (status === "running") return "●"; // running/active
if (status === "partial") return "◐"; // partial/degraded
return "○"; // stopped/inactive
}
/**
* Format compose project list as markdown following STYLE.md section 7.4
*/
export function formatComposeListMarkdown(
projects: ComposeProject[],
host: string,
total?: number,
offset?: number,
hasMore?: boolean,
filters?: Record<string, string | number | boolean>
): string {
if (projects.length === 0) return `No compose projects found on ${host}.`;
// Count by status for status breakdown - STYLE.md Section 7.4
const statusCounts = projects.reduce(
(acc, p) => {
if (p.status === "running") acc.running++;
else if (p.status === "partial") acc.partial++;
else acc.stopped++;
return acc;
},
{ running: 0, partial: 0, stopped: 0 }
);
// Title - STYLE.md Section 3.1
const totalDisplay = total !== undefined ? ` (${total} total)` : "";
const title = `Docker Compose Stacks on ${host}${totalDisplay}`;
// Status breakdown - STYLE.md Section 7.4
const breakdownParts: string[] = [];
if (statusCounts.running > 0) breakdownParts.push(`running: ${statusCounts.running}`);
if (statusCounts.partial > 0) breakdownParts.push(`partial: ${statusCounts.partial}`);
if (statusCounts.stopped > 0) breakdownParts.push(`stopped: ${statusCounts.stopped}`);
const statusBreakdown = `Status breakdown: ${breakdownParts.join(", ")}`;
// Request Echo - STYLE.md Section 3.5 (filters affect output)
const filterLine = filters
? `Filters: ${Object.entries(filters)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}`
: "";
// Legend - STYLE.md Section 3.3 (for mixed states)
const states = new Set(projects.map((p) => p.status));
const hasMultipleStates = states.size > 1;
const legend = hasMultipleStates ? "Legend: ● running ◐ partial ○ stopped" : "";
// STYLE.md Section 3.6: Freshness timestamp for volatile data (discovery scans)
const timestamp = getTimestamp();
const lines = [title, statusBreakdown];
if (filterLine) lines.push(filterLine);
lines.push(timestamp);
if (legend) lines.push(legend);
lines.push(""); // blank line before table
// Table header - STYLE.md Section 5.1
lines.push(" Stack Status Services");
lines.push(" ------------------------- ---------- -------------------------");
// Sort by severity first - STYLE.md Section 12
const sorted = sortBySeverity(
projects,
(p) => p.status,
COMPOSE_PROJECT_SEVERITY,
(p) => p.name
);
for (const p of sorted) {
const symbol = getStatusSymbol(p.status);
// Format services: [N] service1,service2…
const serviceCount = p.services.length;
const serviceNames =
serviceCount === 0
? "—"
: serviceCount <= 2
? `[${serviceCount}] ${p.services.join(",")}`
: `[${serviceCount}] ${p.services.slice(0, 2).join(",")}…`;
// Format row with aligned columns
const nameCol = p.name.length > 25 ? `${p.name.slice(0, 24)}…` : p.name.padEnd(25);
const statusCol = p.status.padEnd(10);
const servicesCol = serviceNames.length > 25 ? `${serviceNames.slice(0, 24)}…` : serviceNames;
lines.push(`${symbol} ${nameCol} ${statusCol} ${servicesCol}`);
}
// Pagination footer - STYLE.md Section 12
if (hasMore && total !== undefined && offset !== undefined) {
lines.push("");
lines.push(
`Showing ${offset + 1}–${offset + projects.length} of ${total} | next_offset: ${offset + projects.length}`
);
}
return lines.join("\n");
}
/**
* Format compose up operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Operation for project on host"
* - 3.2: Summary line with service count
* - 4.1: Canonical ● symbol for active services
*
* @example
* formatComposeUpMarkdown(project, "host1", { servicesStarted: 3 })
* // Returns:
* // Compose Up for myapp on host1
* // Services started: 3
* //
* // ● myapp (3 services)
*/
export function formatComposeUpMarkdown(
projectData: ComposeProject,
host: string,
options: { servicesStarted: number }
): string {
const title = `Compose Up for ${projectData.name} on ${host}`;
if (options.servicesStarted === 0) {
return `${title}\nNo services started`;
}
const summary = `Services started: ${options.servicesStarted}`;
const statusSymbol = "●"; // Canonical running symbol per STYLE.md 4.1
const statusLine = `${statusSymbol} ${projectData.name} (${options.servicesStarted} services)`;
return `${title}\n${summary}\n\n${statusLine}`;
}
/**
* Format compose project status as markdown following STYLE.md section 8.3
*/
export function formatComposeStatusMarkdown(
project: ComposeProject,
totalServices?: number,
offset?: number,
hasMore?: boolean
): string {
const statusSymbol = getStatusSymbol(project.status);
// Title - STYLE.md Section 3.1
const title = `Compose Stack: ${project.name} (${statusSymbol} ${project.status})`;
// Summary - STYLE.md Section 3.2
const summary =
totalServices !== undefined
? `Services: ${totalServices} | Showing: ${offset || 0 + 1}–${(offset || 0) + project.services.length}`
: `Services: ${project.services.length}`;
// STYLE.md Section 3.6: Freshness timestamp for volatile data (status queries)
const timestamp = getTimestamp();
const lines = [title, summary, timestamp];
if (project.services.length === 0) {
lines.push("", "No services found.");
return lines.join("\n");
}
// Legend - STYLE.md Section 3.3 (for mixed service states)
const serviceStates = new Set(project.services.map((s) => s.status));
const hasMultipleStates = serviceStates.size > 1;
const legend = hasMultipleStates ? "Legend: ● running ○ stopped" : "";
if (legend) lines.push(legend);
lines.push(""); // blank line before table
// Table header - STYLE.md Section 5.1
lines.push(" Service Status Health Ports");
lines.push(" ------------------------- ---------- ---------- ---------------");
// Sort by severity: stopped → running
const sorted = sortBySeverity(
project.services,
(s) => s.status,
COMPOSE_SERVICE_SEVERITY,
(s) => s.name
);
for (const svc of sorted) {
const symbol = svc.status === "running" ? "●" : "○";
const health = svc.health || "—";
// Format ports with overflow notation
const ports = svc.publishers?.map((p) => `${p.publishedPort}→${p.targetPort}`) || [];
const portsDisplay =
ports.length === 0
? "—"
: ports.length <= 3
? ports.join(",")
: `${ports.slice(0, 3).join(",")}+${ports.length - 3}`;
// Format row with aligned columns
const nameCol = svc.name.length > 25 ? `${svc.name.slice(0, 24)}…` : svc.name.padEnd(25);
const statusCol = svc.status.padEnd(10);
const healthCol = health.padEnd(10);
const portsCol = portsDisplay.length > 15 ? `${portsDisplay.slice(0, 14)}…` : portsDisplay;
lines.push(`${symbol} ${nameCol} ${statusCol} ${healthCol} ${portsCol}`);
}
// Pagination footer - STYLE.md Section 12
if (hasMore && totalServices !== undefined && offset !== undefined) {
lines.push("");
lines.push(
`Showing ${offset + 1}–${offset + project.services.length} of ${totalServices} | next_offset: ${offset + project.services.length}`
);
}
return lines.join("\n");
}
/**
* Format compose down operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Operation for project on host"
* - 3.2: Summary line with counts
* - 4.1: Canonical ○ symbol for stopped services
*
* @example
* formatComposeDownMarkdown(project, "host1", { servicesStopped: 3, volumesRemoved: 2 })
* // Returns:
* // Compose Down for myapp on host1
* // Services stopped: 3 | Volumes removed: 2
* //
* // ○ myapp (3 services stopped)
*/
export function formatComposeDownMarkdown(
projectData: ComposeProject,
host: string,
options: { servicesStopped: number; volumesRemoved?: number }
): string {
const title = `Compose Down for ${projectData.name} on ${host}`;
if (options.servicesStopped === 0) {
return `${title}\nNo services stopped`;
}
const summaryParts = [`Services stopped: ${options.servicesStopped}`];
if (options.volumesRemoved !== undefined && options.volumesRemoved > 0) {
summaryParts.push(`Volumes removed: ${options.volumesRemoved}`);
}
const summary = summaryParts.join(" | ");
const statusSymbol = "○"; // Stopped symbol per STYLE.md 4.1
const statusLine = `${statusSymbol} ${projectData.name} (${options.servicesStopped} services stopped)`;
return `${title}\n${summary}\n\n${statusLine}`;
}
/**
* Format compose restart operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Operation for project on host"
* - 3.2: Summary line with count
* - 4.1: Canonical ◐ symbol for restarting services
*
* @example
* formatComposeRestartMarkdown(project, "host1", { servicesRestarted: 3 })
* // Returns:
* // Compose Restart for myapp on host1
* // Services restarted: 3
* //
* // ◐ myapp (3 services restarted)
*/
export function formatComposeRestartMarkdown(
projectData: ComposeProject,
host: string,
options: { servicesRestarted: number }
): string {
const title = `Compose Restart for ${projectData.name} on ${host}`;
if (options.servicesRestarted === 0) {
return `${title}\nNo services restarted`;
}
const summary = `Services restarted: ${options.servicesRestarted}`;
const statusSymbol = "◐"; // Restarting symbol per STYLE.md 4.1
const statusLine = `${statusSymbol} ${projectData.name} (${options.servicesRestarted} services restarted)`;
return `${title}\n${summary}\n\n${statusLine}`;
}
/**
* Format compose recreate operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Operation for project on host"
* - 3.2: Summary line with count
* - 4.1: Canonical ● symbol (recreated = running)
*
* @example
* formatComposeRecreateMarkdown(project, "host1", { servicesRecreated: 3 })
* // Returns:
* // Compose Recreate for myapp on host1
* // Services recreated: 3
* //
* // ● myapp (3 services recreated)
*/
export function formatComposeRecreateMarkdown(
projectData: ComposeProject,
host: string,
options: { servicesRecreated: number }
): string {
const title = `Compose Recreate for ${projectData.name} on ${host}`;
if (options.servicesRecreated === 0) {
return `${title}\nNo services recreated`;
}
const summary = `Services recreated: ${options.servicesRecreated}`;
const statusSymbol = "●"; // Running symbol per STYLE.md 4.1
const statusLine = `${statusSymbol} ${projectData.name} (${options.servicesRecreated} services recreated)`;
return `${title}\n${summary}\n\n${statusLine}`;
}
/**
* Format compose pull operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Operation for project on host"
* - 3.2: Summary with counts (conditional parts)
* - 4.1: Canonical ✓ symbol for success
*
* @example
* formatComposePullMarkdown(project, "host1", { imagesPulled: 2, imagesUpToDate: 1 })
* // Returns:
* // Compose Pull for myapp on host1
* // Images pulled: 2 | Up-to-date: 1
* //
* // ✓ myapp (2 images updated, 1 already current)
*/
export function formatComposePullMarkdown(
projectData: ComposeProject,
host: string,
options: { imagesPulled: number; imagesUpToDate: number }
): string {
const title = `Compose Pull for ${projectData.name} on ${host}`;
if (options.imagesPulled === 0 && options.imagesUpToDate === 0) {
return `${title}\nNo images to pull`;
}
const summaryParts: string[] = [];
if (options.imagesPulled > 0) {
summaryParts.push(`Images pulled: ${options.imagesPulled}`);
}
if (options.imagesUpToDate > 0) {
summaryParts.push(`Up-to-date: ${options.imagesUpToDate}`);
}
const summary = summaryParts.join(" | ");
const statusSymbol = "✓"; // Success symbol per STYLE.md 4.1
const statusLine = `${statusSymbol} ${projectData.name} (${options.imagesPulled} images updated, ${options.imagesUpToDate} already current)`;
return `${title}\n${summary}\n\n${statusLine}`;
}
/**
* Format compose build operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Operation for project on host"
* - 3.2: Summary with counts and optional duration
* - 4.1: Canonical ✓ symbol for success
*
* @example
* formatComposeBuildMarkdown(project, "host1", { servicesBuilt: 2, duration: 45 })
* // Returns:
* // Compose Build for myapp on host1
* // Services built: 2 | Duration: 45s
* //
* // ✓ myapp (2 services built in 45s)
*/
export function formatComposeBuildMarkdown(
projectData: ComposeProject,
host: string,
options: { servicesBuilt: number; duration?: number }
): string {
const title = `Compose Build for ${projectData.name} on ${host}`;
if (options.servicesBuilt === 0) {
return `${title}\nNo services built`;
}
const summaryParts = [`Services built: ${options.servicesBuilt}`];
if (options.duration !== undefined) {
summaryParts.push(`Duration: ${options.duration}s`);
}
const summary = summaryParts.join(" | ");
const statusSymbol = "✓"; // Success symbol per STYLE.md 4.1
const statusParts = [`${options.servicesBuilt} services built`];
if (options.duration !== undefined) {
statusParts.push(`in ${options.duration}s`);
}
const statusLine = `${statusSymbol} ${projectData.name} (${statusParts.join(" ")})`;
return `${title}\n${summary}\n\n${statusLine}`;
}
/** Preview threshold for log output - show preview format when exceeding this */
const PREVIEW_THRESHOLD = 10;
/**
* Format compose logs operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Container Logs for project on host"
* - 3.2: Summary with counts and flags
* - 7.5: Preview format (first 5 + last 5) for outputs >10 lines
* - 4.1: Stream labels [stdout]/[stderr]
*
* @example
* formatComposeLogsMarkdown(project, "host1", {
* logs: [{ stream: 'stdout', message: 'line 1' }],
* requested: 10,
* truncated: false,
* follow: false
* })
* // Returns:
* // Container Logs for myapp on host1
* // Lines returned: 1 (requested: 10) | truncated: no | follow: no
* //
* // [stdout] line 1
*/
export function formatComposeLogsMarkdown(
projectData: ComposeProject,
host: string,
options: {
logs: Array<{ stream: "stdout" | "stderr"; message: string }>;
requested: number;
truncated: boolean;
follow: boolean;
}
): string {
const title = `Container Logs for ${projectData.name} on ${host}`;
const summary = [
`Lines returned: ${options.logs.length} (requested: ${options.requested})`,
`truncated: ${options.truncated ? "yes" : "no"}`,
`follow: ${options.follow ? "yes" : "no"}`,
].join(" | ");
// STYLE.md Section 3.6: Freshness timestamp for volatile data (logs)
const timestamp = getTimestamp();
const formatLogLine = (log: { stream: string; message: string }) =>
` [${log.stream}] ${log.message}`;
let logOutput: string;
if (options.logs.length <= PREVIEW_THRESHOLD) {
// Show all lines
logOutput = options.logs.map(formatLogLine).join("\n");
} else {
// Preview format per STYLE.md 7.5
const first5 = options.logs.slice(0, 5).map(formatLogLine).join("\n");
const last5 = options.logs.slice(-5).map(formatLogLine).join("\n");
logOutput = `Preview (first 5):\n${first5}\n ...\n\nPreview (last 5):\n${last5}\n ...`;
}
return `${title}\n${summary}\n${timestamp}\n\n${logOutput}`;
}
/**
* Format compose refresh/discovery operation result.
*
* Follows STYLE.md sections:
* - 3.1: Title format "Operation on host"
* - 3.2: Summary with counts
* - 4.1: Canonical ✓ symbol for success
*
* @example
* formatComposeRefreshMarkdown("host1", { discovered: 2, updated: 1 })
* // Returns:
* // Compose Refresh on host1
* // Discovered: 2 | Updated: 1
* //
* // ✓ Compose projects refreshed (2 discovered, 1 updated)
*/
export function formatComposeRefreshMarkdown(
host: string,
options: { discovered: number; updated: number }
): string {
const title = `Compose Refresh on ${host}`;
if (options.discovered === 0 && options.updated === 0) {
return `${title}\nNo changes detected`;
}
const summary = `Discovered: ${options.discovered} | Updated: ${options.updated}`;
const statusSymbol = "✓"; // Success symbol per STYLE.md 4.1
const statusLine = `${statusSymbol} Compose projects refreshed (${options.discovered} discovered, ${options.updated} updated)`;
return `${title}\n${summary}\n\n${statusLine}`;
}