Skip to content

Commit aa84521

Browse files
committed
added missing files
1 parent 0e549ad commit aa84521

9 files changed

Lines changed: 525 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ artifacts/
305305
__mismatch__
306306
coverage.opencover.xml
307307
output
308+
!src/Nitro/CommandLine/src/CommandLine/Output/
308309
src/All.sln
309310
src/Build.CheckApi.sln
310311
src/Build.Sonar.sln
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace ChilliCream.Nitro.CommandLine.Output;
2+
3+
/// <summary>
4+
/// The explicit error codes emitted by analytical commands in the failure envelope.
5+
/// Consumers can switch on <see cref="OutputEnvelopeError.Code"/> to react to a known
6+
/// failure mode instead of parsing the human-readable message.
7+
/// </summary>
8+
internal static class ErrorCodes
9+
{
10+
public const string CoordinateNotFound = "COORDINATE_NOT_FOUND";
11+
public const string StageNotFound = "STAGE_NOT_FOUND";
12+
public const string ApiNotFound = "API_NOT_FOUND";
13+
public const string NoDataInWindow = "NO_DATA_IN_WINDOW";
14+
public const string NotAuthenticated = "NOT_AUTHENTICATED";
15+
public const string InvalidTimeWindow = "INVALID_TIME_WINDOW";
16+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using System.Text.Json.Serialization.Metadata;
2+
3+
namespace ChilliCream.Nitro.CommandLine.Output;
4+
5+
/// <summary>
6+
/// Shared helper that renders an <see cref="OutputEnvelope{T}"/> error payload in the
7+
/// requested format. Keeps every analytical command's error rendering consistent.
8+
/// </summary>
9+
internal static class ErrorOutputWriter
10+
{
11+
public static void Write<T>(
12+
INitroConsole console,
13+
OutputFormat format,
14+
OutputEnvelope<T> envelope,
15+
JsonTypeInfo<OutputEnvelope<T>> typeInfo)
16+
{
17+
var error = envelope.Error
18+
?? throw new InvalidOperationException(
19+
"ErrorOutputWriter requires an envelope that carries an error payload.");
20+
21+
switch (format)
22+
{
23+
case OutputFormat.Json:
24+
JsonOutputWriter.Write(console, envelope, typeInfo);
25+
break;
26+
27+
case OutputFormat.Markdown:
28+
console.Out.WriteLine(RenderMarkdown(envelope, error));
29+
break;
30+
31+
case OutputFormat.Table:
32+
console.Error.WriteErrorLine($"{error.Code}: {error.Message}");
33+
break;
34+
}
35+
}
36+
37+
private static string RenderMarkdown<T>(OutputEnvelope<T> envelope, OutputEnvelopeError error)
38+
{
39+
var writer = new MarkdownWriter();
40+
41+
writer.Frontmatter(
42+
[
43+
new KeyValuePair<string, string>("api", envelope.Api),
44+
new KeyValuePair<string, string>("stage", envelope.Stage),
45+
new KeyValuePair<string, string>(
46+
"window",
47+
$"{MarkdownWriter.FormatDate(envelope.Window.From)} to {MarkdownWriter.FormatDate(envelope.Window.To)}"),
48+
new KeyValuePair<string, string>("error", error.Code)
49+
]);
50+
51+
writer.Quote($"**Error:** {error.Message}");
52+
53+
return writer.ToString();
54+
}
55+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace ChilliCream.Nitro.CommandLine.Output;
2+
3+
/// <summary>
4+
/// Renders an analytical command payload to a single output format. Implementations are
5+
/// per-command and per-format and do not share state.
6+
/// </summary>
7+
/// <typeparam name="T">The shape of the success payload.</typeparam>
8+
internal interface IOutputFormatter<T>
9+
{
10+
/// <summary>
11+
/// Writes the success envelope to the supplied console.
12+
/// </summary>
13+
void Write(INitroConsole console, OutputEnvelope<T> envelope);
14+
15+
/// <summary>
16+
/// Writes the failure envelope to the supplied console.
17+
/// </summary>
18+
void WriteError(INitroConsole console, OutputEnvelope<T> envelope);
19+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization.Metadata;
3+
4+
namespace ChilliCream.Nitro.CommandLine.Output;
5+
6+
/// <summary>
7+
/// Serialises an <see cref="OutputEnvelope{T}"/> to a single line of JSON using
8+
/// System.Text.Json source generation. Each command supplies the typed metadata for its
9+
/// envelope shape so AOT trimming continues to work.
10+
/// </summary>
11+
internal static class JsonOutputWriter
12+
{
13+
private static readonly JsonSerializerOptions s_options = new()
14+
{
15+
WriteIndented = true,
16+
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
17+
};
18+
19+
public static void Write<T>(
20+
INitroConsole console,
21+
OutputEnvelope<T> envelope,
22+
JsonTypeInfo<OutputEnvelope<T>> typeInfo)
23+
{
24+
var json = JsonSerializer.Serialize(envelope, typeInfo);
25+
console.Out.WriteLine(json);
26+
}
27+
28+
/// <summary>
29+
/// The shared serializer options used by analytical command JSON output. Exposed so that
30+
/// each command's source-generated JSON context can adopt the same formatting
31+
/// (camelCase, indented, null-skipping).
32+
/// </summary>
33+
public static JsonSerializerOptions Options => s_options;
34+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
using System.Globalization;
2+
using System.Text;
3+
4+
namespace ChilliCream.Nitro.CommandLine.Output;
5+
6+
/// <summary>
7+
/// Builds GitHub-flavoured Markdown documents with frontmatter and pipe tables. Designed
8+
/// for analytical command output that a coding agent will paste back into its context
9+
/// window. Cells are escaped per the GFM table specification.
10+
/// </summary>
11+
internal sealed class MarkdownWriter
12+
{
13+
private readonly StringBuilder _buffer = new();
14+
15+
/// <summary>
16+
/// Emits a YAML frontmatter block. Keys are written in the order they appear and values
17+
/// are quoted only when they contain a colon, hash, or leading/trailing whitespace.
18+
/// </summary>
19+
public MarkdownWriter Frontmatter(IReadOnlyList<KeyValuePair<string, string>> entries)
20+
{
21+
_buffer.Append("---\n");
22+
foreach (var entry in entries)
23+
{
24+
_buffer.Append(entry.Key);
25+
_buffer.Append(": ");
26+
_buffer.Append(EscapeFrontmatterValue(entry.Value));
27+
_buffer.Append('\n');
28+
}
29+
_buffer.Append("---\n");
30+
return this;
31+
}
32+
33+
/// <summary>
34+
/// Emits a level-2 heading followed by a blank line.
35+
/// </summary>
36+
public MarkdownWriter Heading(string text)
37+
{
38+
EnsureBlankLineBefore();
39+
_buffer.Append("## ");
40+
_buffer.Append(text);
41+
_buffer.Append('\n');
42+
return this;
43+
}
44+
45+
/// <summary>
46+
/// Emits a single line of body text followed by a newline.
47+
/// </summary>
48+
public MarkdownWriter Line(string text)
49+
{
50+
_buffer.Append(text);
51+
_buffer.Append('\n');
52+
return this;
53+
}
54+
55+
/// <summary>
56+
/// Emits a blockquote. Used by error rendering.
57+
/// </summary>
58+
public MarkdownWriter Quote(string text)
59+
{
60+
EnsureBlankLineBefore();
61+
_buffer.Append("> ");
62+
_buffer.Append(text);
63+
_buffer.Append('\n');
64+
return this;
65+
}
66+
67+
/// <summary>
68+
/// Emits a GFM pipe table with the given headers and rows. Cells with a pipe character
69+
/// are escaped. Empty rows render as a single em-dash to keep the column structure
70+
/// intact.
71+
/// </summary>
72+
public MarkdownWriter Table(
73+
IReadOnlyList<string> headers,
74+
IReadOnlyList<IReadOnlyList<string>> rows)
75+
{
76+
if (headers.Count == 0)
77+
{
78+
return this;
79+
}
80+
81+
EnsureBlankLineBefore();
82+
83+
_buffer.Append('|');
84+
foreach (var header in headers)
85+
{
86+
_buffer.Append(' ');
87+
_buffer.Append(EscapeCell(header));
88+
_buffer.Append(" |");
89+
}
90+
_buffer.Append('\n');
91+
92+
_buffer.Append('|');
93+
for (var i = 0; i < headers.Count; i++)
94+
{
95+
_buffer.Append("---|");
96+
}
97+
_buffer.Append('\n');
98+
99+
foreach (var row in rows)
100+
{
101+
_buffer.Append('|');
102+
for (var i = 0; i < headers.Count; i++)
103+
{
104+
var cell = i < row.Count ? row[i] : string.Empty;
105+
_buffer.Append(' ');
106+
_buffer.Append(EscapeCell(cell));
107+
_buffer.Append(" |");
108+
}
109+
_buffer.Append('\n');
110+
}
111+
112+
return this;
113+
}
114+
115+
/// <summary>
116+
/// Emits a blank line so the next block starts on its own line.
117+
/// </summary>
118+
public MarkdownWriter BlankLine()
119+
{
120+
_buffer.Append('\n');
121+
return this;
122+
}
123+
124+
/// <summary>
125+
/// Emits a horizontal rule used to separate frontmatter+table sections in multi-payload
126+
/// commands such as <c>nitro schema usage --coordinate A --coordinate B</c>.
127+
/// </summary>
128+
public MarkdownWriter SectionBreak()
129+
{
130+
EnsureBlankLineBefore();
131+
_buffer.Append("---\n");
132+
return this;
133+
}
134+
135+
/// <inheritdoc />
136+
public override string ToString()
137+
{
138+
var text = _buffer.ToString();
139+
return text.TrimEnd('\n');
140+
}
141+
142+
private void EnsureBlankLineBefore()
143+
{
144+
if (_buffer.Length == 0)
145+
{
146+
return;
147+
}
148+
149+
if (_buffer[^1] != '\n')
150+
{
151+
_buffer.Append('\n');
152+
_buffer.Append('\n');
153+
return;
154+
}
155+
156+
if (_buffer.Length >= 2 && _buffer[^2] == '\n')
157+
{
158+
return;
159+
}
160+
161+
_buffer.Append('\n');
162+
}
163+
164+
private static string EscapeCell(string value)
165+
{
166+
if (string.IsNullOrEmpty(value))
167+
{
168+
return string.Empty;
169+
}
170+
171+
if (value.IndexOf('|') < 0 && value.IndexOf('\n') < 0)
172+
{
173+
return value;
174+
}
175+
176+
return value.Replace("|", "\\|", StringComparison.Ordinal)
177+
.Replace("\n", " ", StringComparison.Ordinal);
178+
}
179+
180+
private static string EscapeFrontmatterValue(string value)
181+
{
182+
if (string.IsNullOrEmpty(value))
183+
{
184+
return "\"\"";
185+
}
186+
187+
if (value.Contains(':', StringComparison.Ordinal)
188+
|| value.Contains('#', StringComparison.Ordinal)
189+
|| value.Contains('"', StringComparison.Ordinal)
190+
|| char.IsWhiteSpace(value[0])
191+
|| char.IsWhiteSpace(value[^1]))
192+
{
193+
var escaped = value.Replace("\"", "\\\"", StringComparison.Ordinal);
194+
return $"\"{escaped}\"";
195+
}
196+
197+
return value;
198+
}
199+
200+
/// <summary>
201+
/// Formats a long running count with thousands separators using the invariant culture so
202+
/// that snapshot tests are stable across locales.
203+
/// </summary>
204+
public static string FormatCount(long value)
205+
=> value.ToString("N0", CultureInfo.InvariantCulture);
206+
207+
/// <summary>
208+
/// Formats a percentage from a 0..1 fraction using the invariant culture.
209+
/// </summary>
210+
public static string FormatPercent(double value)
211+
=> (value * 100d).ToString("0.##", CultureInfo.InvariantCulture) + "%";
212+
213+
/// <summary>
214+
/// Formats a duration in milliseconds using the invariant culture.
215+
/// </summary>
216+
public static string FormatMilliseconds(double value)
217+
=> value.ToString("0.##", CultureInfo.InvariantCulture) + "ms";
218+
219+
/// <summary>
220+
/// Formats an ISO date for display. <c>null</c> values render as a hyphen.
221+
/// </summary>
222+
public static string FormatDate(DateTimeOffset? value)
223+
=> value is null ? "-" : value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
224+
225+
/// <summary>
226+
/// Formats a UTC ISO date-time for display.
227+
/// </summary>
228+
public static string FormatDateTime(DateTimeOffset value)
229+
=> value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
230+
}

0 commit comments

Comments
 (0)